diff --git a/.github/patches/apply_flutter_3.44_source_patches.sh b/.github/patches/apply_flutter_3.44_source_patches.sh index 3a7ab99dc7a..2b4bfcc0d87 100644 --- a/.github/patches/apply_flutter_3.44_source_patches.sh +++ b/.github/patches/apply_flutter_3.44_source_patches.sh @@ -17,6 +17,109 @@ # therefore CRLF-safe. set -euo pipefail +readonly NO_MATCHES=0 +readonly SINGLE_MATCH=1 +readonly THEME_MATCHES=2 + +has_exact_count() { + local -r expected_count="$1" + local -r pattern="$2" + local -r file="$3" + local actual_count + [[ -r "$file" ]] || return 1 + actual_count="$(grep -cF "$pattern" "$file" || true)" + [[ "$actual_count" -eq "$expected_count" ]] +} + +# The target background-color line must directly follow DialogThemeData in the selected range. +has_dialog_background_in_theme_range() { + local -r start_pattern="$1" + local -r end_pattern="$2" + local -r target_pattern="$3" + local -r file="$4" + awk -v start_pattern="$start_pattern" \ + -v end_pattern="$end_pattern" \ + -v target_pattern="$target_pattern" ' + index($0, start_pattern) { + in_theme = 1 + next + } + in_theme && index($0, end_pattern) { + exit + } + in_theme && index($0, "dialogTheme: DialogThemeData(") { + if (getline > 0) { + line = $0 + sub(/\r$/, "", line) + sub(/^[[:space:]]+/, "", line) + matched = line == target_pattern + } + exit + } + END { + exit matched ? 0 : 1 + } + ' "$file" +} + +validate_patch_inputs() { + if [[ ! -f flutter/lib/common.dart || ! -r flutter/lib/common.dart ]]; then + echo "Flutter 3.44 source patch input is missing or unreadable: flutter/lib/common.dart" >&2 + return 1 + fi + if [[ ! -f flutter/pubspec.yaml || ! -r flutter/pubspec.yaml ]]; then + echo "Flutter 3.44 source patch input is missing or unreadable: flutter/pubspec.yaml" >&2 + return 1 + fi +} + +is_complete_patch_state() { + has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart && + has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'backgroundColor: Colors.white,' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'extended_text: 15.0.2' flutter/pubspec.yaml && + has_exact_count "$SINGLE_MATCH" 'google_fonts: ^8.1.0' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'extended_text: 14.0.0' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'google_fonts: ^6.2.1' flutter/pubspec.yaml && + has_dialog_background_in_theme_range 'static ThemeData lightTheme = ThemeData(' \ + 'static ThemeData darkTheme = ThemeData(' 'backgroundColor: Colors.white,' \ + flutter/lib/common.dart && + has_dialog_background_in_theme_range 'static ThemeData darkTheme = ThemeData(' \ + 'scrollbarTheme: scrollbarThemeDark,' 'backgroundColor: Color(0xFF18191E),' \ + flutter/lib/common.dart +} + +is_unpatched_state() { + has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart && + has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart && + has_exact_count "$SINGLE_MATCH" 'extended_text: 14.0.0' flutter/pubspec.yaml && + has_exact_count "$SINGLE_MATCH" 'google_fonts: ^6.2.1' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'backgroundColor: Colors.white,' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart && + has_exact_count "$NO_MATCHES" 'extended_text: 15.0.2' flutter/pubspec.yaml && + has_exact_count "$NO_MATCHES" 'google_fonts: ^8.1.0' flutter/pubspec.yaml +} + +if ! validate_patch_inputs; then + exit 1 +fi + +if is_complete_patch_state; then + echo "Flutter 3.44 source patches already applied." + git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml + exit 0 +fi + +if ! is_unpatched_state; then + echo "Flutter 3.44 source patches are partially applied or their anchors have drifted." >&2 + exit 1 +fi + # ThemeData API renames (Flutter 3.27+): sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart @@ -28,12 +131,10 @@ sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThem sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml -# Fail loudly if any expected string drifted, so we never silently build unpatched: -grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart -grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart -grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart -grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart -grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml -grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml +# Fail loudly if any expected substitution did not produce the complete state. +if ! is_complete_patch_state; then + echo "Flutter 3.44 source patches did not produce the expected state." >&2 + exit 1 +fi git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml diff --git a/.github/patches/apply_flutter_3.44_web_patches.sh b/.github/patches/apply_flutter_3.44_web_patches.sh new file mode 100755 index 00000000000..24ce7f1b4c3 --- /dev/null +++ b/.github/patches/apply_flutter_3.44_web_patches.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Prepares a web build on Flutter 3.44.x. Companion to +# apply_flutter_3.44_source_patches.sh (which it runs first): the web target +# additionally needs qr_code_scanner's web implementation patched for the +# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with +# the font paths the 3.44 engine requests for offline/air-gapped support +# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py). +# +# Run from the repository root with Flutter 3.44.x on PATH, then build: +# bash .github/patches/apply_flutter_3.44_web_patches.sh +# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py +# +# Idempotent. To undo the source changes locally: +# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock +set -euo pipefail + +flutter --version | grep -q "Flutter 3\.44\." || { + echo "Flutter 3.44.x must be on PATH; found:" >&2 + flutter --version | grep "^Flutter" >&2 || true + exit 1 +} + +# Shared 3.44 source/pubspec patches own their complete-state validation. +bash .github/patches/apply_flutter_3.44_source_patches.sh + +# Populate the pub cache with the 3.44 dependency resolution. +(cd flutter && flutter pub get) + +# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from +# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The +# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so +# mutating the shared pub cache is safe for other local builds. +QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart" +if ! grep -qF "dart:ui_web" "$QR_WEB"; then + sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB" + rm -f "$QR_WEB.bak" +fi +if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then + sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB" + rm -f "$QR_WEB.bak" +fi + +# Mirror the fonts this engine version requests into flutter/web/fonts. +python3 flutter/web/fonts/sync_fonts.py + +# Fail loudly if any expected state is missing: +grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB" +grep -qF "ui_web.platformViewRegistry" "$QR_WEB" +grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml + +echo "Flutter 3.44 web patches applied." diff --git a/.github/scripts/write_artifact_manifest.py b/.github/scripts/write_artifact_manifest.py index 06074691c77..a9c0e4728c8 100644 --- a/.github/scripts/write_artifact_manifest.py +++ b/.github/scripts/write_artifact_manifest.py @@ -291,13 +291,12 @@ def main() -> None: raise SystemExit("manifest production requires platform, app name, and version") output = output_root(args.output) names = expected_names(args.platform, args.app_name, args.version) + private_path = output / PRIVATE_FILENAME + if private_path.exists() or private_path.is_symlink(): + raise SystemExit("public manifest output must not contain custom_.txt") validate_output_tree(output, set(names)) paths = [safe_output_file(output, name) for name in names] private_filenames: list[str] = [] - private_path = output / PRIVATE_FILENAME - if private_path.exists() or private_path.is_symlink(): - safe_output_file(output, PRIVATE_FILENAME) - private_filenames.append(PRIVATE_FILENAME) file_records: list[dict[str, str | int]] = [] for name, path in zip(names, paths, strict=True): before = path.lstat() diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 114ad23da74..e57eb3e6f93 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -39,7 +39,7 @@ jobs: target: x86_64-unknown-linux-gnu, os: ubuntu-22.04, extra-build-args: "", - flutter-version: "3.44.0", + flutter-version: "3.44.8", artifact-name: "bridge-artifact-flutter-3.44", } steps: diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index b721b2a30d2..2489c45eb33 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -13,7 +13,7 @@ name: rustqs android min test # Android custom_.txt is packaged as a Flutter asset and handed to the native # server from MainService before the server thread starts. # -# Путь в форке: .github/workflows/rustqs-android.yml на ветке rustqs/min-test. +# Путь в форке: .github/workflows/rustqs-android.yml на ветке rustqs/workflows. # ============================================================================ on: @@ -681,9 +681,6 @@ jobs: set -euo pipefail MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') export MANIFEST_PUBLICATION_TIMESTAMP - if [ -f flutter/assets/custom_.txt ]; then - cp -- flutter/assets/custom_.txt output/custom_.txt - fi test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } python3 "$MANIFEST_HELPER_PATH" \ --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 2fcb196b754..8182b5f7a6f 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -10,7 +10,7 @@ name: rustqs linux min test # Контракт: единственный build path — authenticated DFP1 enc_payload от API. # Direct/manual runs without a valid payload fail closed before checkout/build. # -# Путь в форке: .github/workflows/rustqs-linux.yml на ветке rustqs/min-test. +# Путь в форке: .github/workflows/rustqs-linux.yml на ветке rustqs/workflows. # ============================================================================ on: @@ -489,7 +489,7 @@ jobs: git apply flutter_3.24.4_dropdown_menu_enableFilter.diff # Historical two-stage build shape: first cargo lib with the required features, - # then build.py for the Flutter UI and packages. The old upstream flutter-build.yml + # then build.py for the Flutter UI and packages. The former upstream workflow # reference is not the active workflow source. - name: Build rustdesk (Flutter Linux) run: | @@ -560,9 +560,6 @@ jobs: set -euo pipefail MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') export MANIFEST_PUBLICATION_TIMESTAMP - if [ -f flutter/build/linux/x64/release/bundle/custom_.txt ]; then - cp -- flutter/build/linux/x64/release/bundle/custom_.txt output/custom_.txt - fi test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } python3 "$MANIFEST_HELPER_PATH" \ --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ diff --git a/.github/workflows/rustqs-windows.yml b/.github/workflows/rustqs-windows.yml index 4f5abcc7006..2f3bebef2f7 100644 --- a/.github/workflows/rustqs-windows.yml +++ b/.github/workflows/rustqs-windows.yml @@ -1,7 +1,7 @@ name: rustqs windows min test # Workflow сборки rustqs.exe (Flutter Windows) в форке rustdesk. # Historical source note: the steps were adapted from build-for-windows-flutter@1.4.7. -# The current fork source/ref is 1.4.8; active dispatch uses authenticated DFP1 only. +# The current fork source/ref is 1.4.9; active dispatch uses authenticated DFP1 only. # (L1 config.rs server+key, L2 allowCustom+custom_.txt, L3 brand+rename exe). # # Единственный build path: enc_payload — authenticated DFP1 AES-CBC + HMAC @@ -39,6 +39,7 @@ jobs: topmost: uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml + needs: bridge with: upload-artifact: true target: windows-2022 @@ -700,9 +701,6 @@ jobs: set -euo pipefail MANIFEST_PUBLICATION_TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ') export MANIFEST_PUBLICATION_TIMESTAMP - if [ -f flutter/build/windows/x64/runner/Release/custom_.txt ]; then - cp -- flutter/build/windows/x64/runner/Release/custom_.txt output/custom_.txt - fi test -f "${MANIFEST_HELPER_PATH:-}" || { echo "::error::preserved manifest helper is unavailable"; exit 1; } python3 "$MANIFEST_HELPER_PATH" \ --platform "$MANIFEST_PLATFORM" --app-name "$RQS_APP_NAME" --version "$RQS_VERSION" \ diff --git a/.github/workflows/update-webpki-roots.yml b/.github/workflows/update-webpki-roots.yml new file mode 100644 index 00000000000..e1efdb0d6cd --- /dev/null +++ b/.github/workflows/update-webpki-roots.yml @@ -0,0 +1,71 @@ +name: Update webpki-roots + +# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots +# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at +# whatever Cargo.lock pins, so old builds miss newly added CAs and keep +# removed (distrusted) ones. Changes go through a PR on purpose: added or +# removed roots should be reviewed, not silently baked into releases. +# +# Note: PRs created with the default GITHUB_TOKEN do not trigger other +# workflows (GitHub limitation). Close and reopen the PR, or push to its +# branch, to run CI on it. + +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +# A manual dispatch overlapping the weekly run would race it force-pushing +# the same branch; queue instead of overlapping, and never cancel a run +# that may have already pushed. +concurrency: + group: update-webpki-roots + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + BRANCH: auto-update-webpki-roots + steps: + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Update webpki-roots in all lockfiles + id: update + run: | + set -e + git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do + dir=$(dirname "$lock") + for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do + echo "updating webpki-roots@$v in $dir" + (cd "$dir" && cargo update -p "webpki-roots@$v") + done + done + if git diff --quiet -- '*Cargo.lock'; then + echo "changed=0" >> "$GITHUB_OUTPUT" + else + echo "changed=1" >> "$GITHUB_OUTPUT" + git --no-pager diff -- '*Cargo.lock' + fi + + - name: Create pull request + if: steps.update.outputs.changed == '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -- '*Cargo.lock' + git commit -m "chore: update webpki-roots to latest Mozilla root store" + git push -f origin "$BRANCH" + if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then + gh pr create \ + --title "chore: update webpki-roots to latest Mozilla root store" \ + --body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it." + fi diff --git a/.gitignore b/.gitignore index d2e09a9066c..f51a5b8cd20 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# libdrmtap is cloned at build time by build.py (not a submodule) +/third_party/libdrmtap/ diff --git a/AGENTS.md b/AGENTS.md index b9a3518f50a..7ab98087d8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,19 @@ * Do not make formatting-only changes. * Keep naming/style consistent with nearby code. +### Comments + +* Keep them short: one line by default, three at most. +* Say **why**, never what. If the code already says it, delete the comment. +* A comment must never be longer than the code it describes. +* Applies to YAML, shell and Python too, not just Rust. + +### Be minimally invasive + +* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none. +* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding). +* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks. + ## Localization (`src/lang/*.rs`) Each file is a `HashMap`. Layout: @@ -84,3 +97,9 @@ Then translate that source into the file's target language (infer the language f * Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source. * Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc. * Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`. + +### Adding new keys (feature work) + +* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them. +* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys). +* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list. diff --git a/Cargo.lock b/Cargo.lock index 8ec2d4a5327..9272b562adb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,26 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2 1.0.93", + "quote 1.0.36", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.98", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -966,27 +986,6 @@ dependencies = [ "serde 1.0.228", ] -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.11+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "cacao" version = "0.4.0-beta2" @@ -1457,6 +1456,8 @@ dependencies = [ "compression-core", "flate2", "memchr", + "zstd", + "zstd-safe", ] [[package]] @@ -1527,12 +1528,6 @@ dependencies = [ "unicode-xid 0.2.4", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "constant_time_eq" version = "0.2.6" @@ -2329,7 +2324,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.4", ] [[package]] @@ -2694,7 +2689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3052,9 +3047,8 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +version = "0.16.0" +source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877" dependencies = [ "libc", "log", @@ -3799,12 +3793,12 @@ dependencies = [ "url", "users 0.11.0", "uuid", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", "webrtc", "whoami", "winapi 0.3.9", "x11 2.21.0", - "zstd 0.13.1", + "zstd", ] [[package]] @@ -3998,7 +3992,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -4494,7 +4488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -6036,35 +6030,12 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - [[package]] name = "peeking_take_while" version = "0.1.2" @@ -6588,7 +6559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556af5f5c953a2ee13f45753e581a38f9778e6551bc3ccc56d90b14628fe59d8" dependencies = [ "cfg-if 0.1.10", - "rpassword 2.1.0", + "rpassword", "tempfile", "termios 0.3.3", "winapi 0.3.9", @@ -6920,7 +6891,7 @@ dependencies = [ [[package]] name = "rdev" version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855" +source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f" dependencies = [ "cocoa 0.24.1", "core-foundation 0.9.4", @@ -7090,7 +7061,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -7152,17 +7123,6 @@ dependencies = [ "winapi 0.2.8", ] -[[package]] -name = "rpassword" -version = "7.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.48.0", -] - [[package]] name = "rtcp" version = "0.14.0" @@ -7174,16 +7134,6 @@ dependencies = [ "webrtc-util", ] -[[package]] -name = "rtoolbox" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "rtp" version = "0.14.0" @@ -7211,18 +7161,6 @@ dependencies = [ "realfft", ] -[[package]] -name = "runas" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96d6b6c505282b007a9b009f2aa38b2fd0359b81a0430ceacc60f69ade4c6a0" -dependencies = [ - "libc", - "security-framework-sys", - "which", - "windows-sys 0.48.0", -] - [[package]] name = "rust-ini" version = "0.18.0" @@ -7270,7 +7208,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.8" +version = "1.4.9" dependencies = [ "android-wakelock", "android_logger", @@ -7283,7 +7221,6 @@ dependencies = [ "cfg-if 1.0.0", "chrono", "cidr-utils", - "clap 4.5.53", "clipboard", "clipboard-master", "cocoa 0.24.1", @@ -7341,9 +7278,7 @@ dependencies = [ "repng", "reqwest", "ringbuf", - "rpassword 7.3.1", "rubato", - "runas", "rust-pulsectl", "samplerate", "sciter-rs", @@ -7380,12 +7315,11 @@ dependencies = [ "wol-rs", "x11-clipboard 0.8.1", "x11rb 0.12.0", - "zip", ] [[package]] name = "rustdesk-portable-packer" -version = "1.4.8" +version = "1.4.9" dependencies = [ "brotli", "dirs 5.0.1", @@ -7457,7 +7391,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7514,7 +7448,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7601,7 +7535,7 @@ name = "scrap" version = "0.5.0" dependencies = [ "android_logger", - "bindgen 0.65.1", + "bindgen 0.72.1", "block", "cfg-if 1.0.0", "dbus", @@ -8827,7 +8761,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -8922,7 +8856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f" dependencies = [ "base32", - "constant_time_eq 0.2.6", + "constant_time_eq", "hmac", "rand 0.8.5", "sha1", @@ -9141,7 +9075,7 @@ dependencies = [ "sha1", "thiserror 2.0.17", "utf-8", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -9814,18 +9748,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.9" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "rustls-pki-types", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -11172,52 +11106,13 @@ dependencies = [ "syn 2.0.98", ] -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq 0.1.5", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2", - "sha1", - "time 0.3.36", - "zstd 0.11.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - [[package]] name = "zstd" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a" dependencies = [ - "zstd-safe 7.1.0", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", + "zstd-safe", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 05c32ab42c1..588cbd96aed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.8" +version = "1.4.9" authors = ["rustdesk "] edition = "2021" build= "build.rs" @@ -22,7 +22,6 @@ path = "src/service.rs" [features] inline = [] -cli = [] use_samplerate = ["samplerate"] use_rubato = ["rubato"] use_dasp = ["dasp"] @@ -31,7 +30,13 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] -plugin_framework = [] +drm = ["scrap/drm"] +# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend +# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the +# root service so a compositor that idle-disabled its outputs re-enables them. That is a different +# kind of operation and deserves a switch that can remove it from the binary entirely, without +# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in. +drm-wake = ["drm"] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ "dep:x11-clipboard", @@ -62,8 +67,6 @@ dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpol rubato = { version = "0.12", optional = true } samplerate = { version = "0.2", optional = true } uuid = { version = "1.3", features = ["v4"] } -clap = "4.2" -rpassword = "7.2" num_cpus = "1.15" bytes = { version = "1.4", features = ["serde"] } default-net = "0.14" @@ -77,12 +80,11 @@ hex = "0.4" chrono = "0.4" cidr-utils = "0.5" fon = "0.6" -zip = "0.6" shutdown_hooks = "0.1" totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] } stunclient = "0.4" kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"} -reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false } +reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false } [target.'cfg(not(target_os = "linux"))'.dependencies] # https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux @@ -127,14 +129,18 @@ windows = { version = "0.61", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System", + "Win32_System_Com", "Win32_System_Diagnostics", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", + "Win32_System_Registry", + "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", ] } winreg = "0.11" windows-service = "0.6" @@ -143,7 +149,6 @@ remote_printer = { path = "libs/remote_printer" } impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" } shared_memory = "0.12" tauri-winrt-notification = "0.1" -runas = "1.2" [target.'cfg(target_os = "macos")'.dependencies] objc = "0.2" @@ -205,7 +210,7 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" } [workspace] members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"] -exclude = ["vdi/host", "examples/custom_plugin"] +exclude = ["vdi/host"] # Patch libxdo-sys to use a stub implementation that doesn't require libxdo # This allows building and running on systems without libxdo installed (e.g., Wayland-only) @@ -213,7 +218,7 @@ exclude = ["vdi/host", "examples/custom_plugin"] libxdo-sys = { path = "libs/libxdo-sys-stub" } [package.metadata.winres] -LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved." +LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." ProductName = "RustDesk" FileDescription = "RustDesk Remote Desktop" OriginalFilename = "rustdesk.exe" diff --git a/README.md b/README.md index b2541182669..c820a13bec6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB ## Dependencies -Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. For this fork's current client-build reference, see the [fork-specific Windows workflow](.github/workflows/rustqs-windows-min-test.yml). The workflow file is a source reference; it is not a release or support claim. +Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. For this fork's current client-build reference, see the [fork-specific Windows workflow](.github/workflows/rustqs-windows.yml). The workflow file is a source reference; it is not a release or support claim. Please download Sciter dynamic library yourself. diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index 84f77a8734f..bad4e84db94 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.8 + version: 1.4.9 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index af4f8103d79..7cd52b89a5b 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.8 + version: 1.4.9 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/build.py b/build.py index 4935529f605..677bd09df2b 100755 --- a/build.py +++ b/build.py @@ -1,16 +1,25 @@ #!/usr/bin/env python3 import os +import glob +import contextlib import pathlib import platform import zipfile import urllib.request import shutil import hashlib +import re +import subprocess import argparse import sys from pathlib import Path +# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__ +# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04 +# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code. +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) + windows = platform.platform().startswith('Windows') osx = platform.platform().startswith( 'Darwin') or platform.platform().startswith("macOS") @@ -130,6 +139,19 @@ def make_parser(): action='store_true', help='Build with unix file copy paste feature' ) + parser.add_argument( + '--drm', + action='store_true', + help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, ' + 'dlopen-ed in-process by the root service). Off by default.' + ) + parser.add_argument( + '--print-features', + action='store_true', + help='Print the cargo feature list these flags select, and exit without building. For a ' + 'caller that runs its own cargo line and then packages with --skip-cargo: it can ask ' + 'for the list rather than repeat it, so the two cannot drift.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -272,6 +294,24 @@ def external_resources(flutter, args, res_dir): shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}') +def linux_packaging_branch(): + """Which packaging path `main()` will take on THIS host. + + MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can + refuse a branch that is not drm-aware instead of silently producing a stock-named package with + the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which + is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged + binary really is a drm build. + """ + if os.path.isfile('/usr/bin/pacman'): + return 'pacman' + if os.path.isfile('/usr/bin/yum'): + return 'yum' + if os.path.isfile('/usr/bin/zypper'): + return 'zypper' + return 'deb' + + def get_features(args): features = ['inline'] if not args.flutter else [] if args.hwcodec: @@ -282,6 +322,30 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if args.drm: + # Say so rather than quietly handing back a stock build: the backend is Linux-only, so on + # any other host the flag cannot be honoured and the resulting binary would look like a + # DRM build without being one. + if windows or osx: + raise Exception('--drm is Linux only') + # And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package + # straight from `target/release` without bundling libdrmtap, without the rename, without + # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a + # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput + # injection. The separate package name is the informed consent this feature rests on, so + # refuse rather than ship a stock-named build of it. + branch = linux_packaging_branch() + if branch != 'deb': + raise Exception( + f'--drm is only supported on the deb packaging path; this host would package via ' + f'{branch}, which cannot bundle libdrmtap or name the package distinctly') + features.append('drm') + # The display wake is its own compile gate on top of `drm`, and the unattended package is + # exactly where it belongs: that variant exists to reach a machine nobody is sitting at, + # and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from + # this line builds the same capture backend with no wake code in the binary at all. + # It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE. + features.append('drm-wake') if osx: if args.screencapturekit: features.append('screencapturekit') @@ -328,6 +392,328 @@ def stage_custom_txt_for_linux_bundle(destination=None): shutil.copy2(source_path, destination) destination.chmod(0o600) +# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned +# commit — the same way rustdesk sources its other native build deps (vcpkg, +# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY +# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on +# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and +# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing +# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below). +# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see +# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in +# any workflow, so a bump is one edit here (plus the informational version comment in +# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4. +LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap' +LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290' +LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED) +LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED) +# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the +# claim this feature rests on -- that the privileged capture library is the reviewed object at +# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a +# build that silently used something else would be indistinguishable from one that did not. +# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch +# and the sha verification and hands over an object built from nothing this script can see. +DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1' + + +def _validate_libdrmtap_pin(): + # Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay + # byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the + # environment (or a malformed sha) must not be able to fail a build that never touches + # libdrmtap. + # `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(), + # which tests it for truthiness. + prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None + overridden = [ + name + for name, value, pinned in ( + ('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED), + ('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED), + ('DRMTAP_PREBUILT_DIR', prebuilt, None), + ) + if value != pinned + ] + if overridden and not DRMTAP_UNPINNED_OK: + raise Exception( + f'{", ".join(overridden)} would build libdrmtap from something other than the pinned ' + f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and ' + 'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.') + if overridden: + print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)') + # Both are interpolated into shell commands below, and both are env-overridable, so validate + # their SHAPE before they get there. This is not only about a hostile environment: a truncated + # or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious + # than saying so here, and an abbreviated one would defeat the point of pinning. + if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA): + raise Exception( + f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}') + if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO): + raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}') + + +def _single_real_so(paths, where): + # Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several. + # glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left + # over from an earlier build; a mismatch should fail the build loudly instead. + real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p)) + if len(real) != 1: + raise Exception( + f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}') + return real[0] + + +def build_libdrmtap_so(): + # Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The + # pivot dlopen-s this .so in-process in the root service (which already holds + # CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared + # library target is built (the source also carries a helper binary we do not + # ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x). + _validate_libdrmtap_pin() + # Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via + # DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object). + prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR') + if prebuilt_dir: + # DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail + # (rather than silently falling back to a source build) if it holds no single real .so. + prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*')) + so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}') + # Check the stub case HERE too, not only on the source path below. This is the widest + # override of the three -- no fetch, no sha verification, an object built by something this + # script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the + # assertion on exactly this path would leave the check guarding only the case that was + # already trustworthy. + _assert_so_has_egl(so) + return so + # Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule + # anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch: + # `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to + # `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable + # object. Fetching the sha needs no branch name, so it keeps working across every upstream push and + # is immune to a ref being moved or repointed. + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') + if not os.path.exists(os.path.join(src, 'meson.build')): + if os.path.isdir(src): + shutil.rmtree(src) + os.makedirs(src, exist_ok=True) + system2(f'git -C "{src}" init -q') + system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}') + system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}') + system2(f'git -C "{src}" checkout -q FETCH_HEAD') + # Verify the pin and cleanliness whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything + # else, so this now guards the OTHER case: a reused checkout left by an earlier build at a + # different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches + # cleanly. A NON-git or otherwise unverifiable tree placed here on purpose (a developer building + # unreleased local libdrmtap source) may be used only with an explicit local opt-in. + git_dir = os.path.join(src, '.git') + if os.path.exists(git_dir): + try: + got_sha = subprocess.check_output( + ['git', '-C', src, 'rev-parse', '--verify', 'HEAD^{commit}'], + stderr=subprocess.STDOUT, + ).decode().strip() + dirty = subprocess.check_output( + ['git', '-C', src, 'status', '--porcelain=v1', '--untracked-files=all'], + stderr=subprocess.STDOUT, + ).decode() + except (subprocess.SubprocessError, OSError, UnicodeError): + if not DRMTAP_UNPINNED_OK: + raise Exception( + f'libdrmtap at {src} is an unverifiable or dirty git tree; ' + 'set DRMTAP_ALLOW_UNPINNED=1 for local source') + print('WARNING: libdrmtap source tree is unverifiable or dirty; DRMTAP_ALLOW_UNPINNED=1 set') + else: + if got_sha != LIBDRMTAP_SHA: + shutil.rmtree(src, ignore_errors=True) + raise Exception( + f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} ' + f'(stale checkout from a different pin; removed, re-run to re-fetch)') + if dirty: + if not DRMTAP_UNPINNED_OK: + raise Exception( + f'libdrmtap at {src} is a dirty checkout; ' + 'set DRMTAP_ALLOW_UNPINNED=1 for local source') + print('WARNING: libdrmtap source tree is dirty; DRMTAP_ALLOW_UNPINNED=1 set') + elif not DRMTAP_UNPINNED_OK: + raise Exception( + f'libdrmtap at {src} is a non-git source tree; ' + 'set DRMTAP_ALLOW_UNPINNED=1 for local source') + else: + print('WARNING: libdrmtap source tree is non-git; DRMTAP_ALLOW_UNPINNED=1 set') + build_dir = os.path.join(src, 'build-pkg') + if not os.path.exists(os.path.join(build_dir, 'build.ninja')): + system2(f'meson setup "{build_dir}" "{src}" --buildtype=release') + # Build only the shared library, not the bundled helper binary or the static archive. Since + # libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the + # bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for + # the shared one explicitly (rustdesk dlopens the .so and never needs the archive). + system2(f'meson compile -C "{build_dir}" drmtap:shared_library') + sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*')) + # keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and + # require exactly one so a stale object from an earlier build is never silently picked. + so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}') + _assert_so_has_egl(so) + return so + + +def _assert_so_has_egl(so_path): + # libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config + # files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader + # checks for, so nothing downstream notices -- and the split architecture depends entirely on the + # unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture + # quietly degrades to PipeWire on every tiled-scanout host, which is most of them. + # + # Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists + # only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or + # hand-substituted object, which a build flag cannot. + # + # EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU + # stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a + # perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really + # lacks. + try: + with open(so_path, 'rb') as f: + blob = f.read() + except OSError as err: + raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err + missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob] + if missing: + raise Exception( + f'{so_path} looks like a CPU-only libdrmtap stub (missing ' + f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture ' + 'depends on is not in it, and DRM capture would silently fall back to PipeWire. ' + 'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev ' + 'libgles2-mesa-dev; Arch: mesa libglvnd).') + + +DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland' + + +def assert_so_satisfies_the_runtime_abi_gate(so_path): + """The .so we are about to ship must be one the RUNTIME will actually accept. + + `abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's + version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the + build or in CI compared the two, so the pin and the gate could drift apart and every existing + assertion would still pass: the EGL check does not look at the version, the CI symbol contract + does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`. + A green pipeline could therefore produce a deb in which DRM capture can never start, and the + only symptom on the host is one log line before it falls back to the portal. + + So parse the gate out of the Rust and apply it here, to the object being staged. This is the + same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones. + """ + m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path)) + if not m: + # Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and + # inventing a verdict would be worse than saying so. + print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check') + return + so_ver = tuple(int(g) for g in m.groups()) + # REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now. + gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') + with open(gate_path) as f: + gate_src = f.read() + + def _const(name): + mm = re.search(rf'const {name}: c_int = (\d+);', gate_src) + return int(mm.group(1)) if mm else None + + major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR') + mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src) + floor = (int(mm.group(1)), int(mm.group(2))) if mm else None + if major is None or minor is None or floor is None: + raise Exception( + 'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / ' + 'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; ' + 'fix the check rather than removing it, or the pin and the gate can drift silently.') + accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor + if not accepted: + raise Exception( + f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the ' + f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor ' + f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never ' + 'start. Move the build pin and the gate together, or fix whichever one is wrong.') + print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate ' + f'(major {major}, minor {minor}, patch >= {floor[1]})') + + +def stage_libdrmtap_into_deb(so_path): + # Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname + # symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at + # the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the + # system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system + # library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in + # and no ldconfig trigger are shipped, so the stock postinst is used unchanged. + assert_so_satisfies_the_runtime_abi_gate(so_path) + so_basename = os.path.basename(so_path) + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + # Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can + # contain a space, and an unquoted interpolation would split the argument and fail obscurely. + system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/') + system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') + + +def _max_glibc_minor(path): + # Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because + # librustdesk.so is ~45 MB. + best = 0 + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return best + for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk): + best = max(best, int(m.group(1))) + tail = chunk[-16:] + + +def measured_glibc_floor(): + # libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged + # object is higher -- and it moves whenever either base does. + paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*') + + glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') + if os.path.isfile(p) and not os.path.islink(p)] + minor = max((_max_glibc_minor(p) for p in paths), default=0) + if not minor: + raise Exception( + f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); ' + 'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which ' + 'is what lets it install on a host where libdrmtap can never load') + return f'2.{minor}' + + +def retarget_control_to_drm_variant(): + # Rewrite the control file that generate_control_file just produced, instead of parameterizing that + # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to + # this variant lives here. The variant installs the same files as the stock package, so it must + # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's + # own runtime deps, which the stock package has no reason to carry. + path = '../res/DEBIAN/control' + floor = measured_glibc_floor() + print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}') + with open(path) as f: + lines = f.readlines() + out = [] + for line in lines: + if line.startswith('Package: rustdesk'): + out.append(f'Package: {DRM_PACKAGE_NAME}\n') + out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') + elif line.startswith('Depends:'): + # 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture. + out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, ' + f'libc6 (>= {floor})\n') + else: + out.append(line) + body = ''.join(out) + # Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file + # that stopped matching either anchor would otherwise produce a variant deb wearing the stock name. + if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body: + raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed') + with open(path, 'w') as f: + f.write(body) + def build_flutter_deb(version, features): if not skip_cargo: @@ -366,9 +752,22 @@ def build_flutter_deb(version, features): 'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages + # stay exactly what they were. The root service dlopens it in-process by absolute path. + # `features` is the comma-joined string, so split it: a bare substring test would also match any + # future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the + # consent-bypass variant without --drm ever being passed. + ships_so = 'drm' in features.split(',') + if ships_so: + # Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the + # binary, so `features` says what was ASKED for while the staged bundle can be anything. + assert_staged_binary_is_drm() + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + if ships_so: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -376,10 +775,68 @@ def build_flutter_deb(version, features): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if ships_so: + # Named apart from the stock package so installing the consent-free variant is a deliberate act. + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") -def build_deb_from_folder(version, binary_folder): +DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0' +# Present only when `drm-wake` is compiled in: the runtime option constant is itself +# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it - +# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that +# is exactly the deb this assertion is here to refuse. +DRMTAP_WAKE_MARKER = b'enable-drm-display-wake' + + +def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER): + # Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary: + # librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with` + # closes deterministically instead of relying on refcounting. + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return False + if marker in tail + chunk: + return True + tail = chunk[-(len(marker) - 1):] + + +def assert_staged_binary_is_drm(): + """The staged BINARY must really be a drm build before it is named the unattended-wayland + variant. That package conflicts with and replaces the stock one, so shipping a stock binary + under that name produces something that can never capture and cannot be installed alongside + what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when + the feature is compiled in -- assert what was produced, not what was asked for. + + Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which + is how CI packages) reaches the other, where nothing had rebuilt the binary at all. + """ + binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)] + if not any(_carries_drmtap_marker(p) for p in binaries): + raise Exception( + f'--drm was requested but the staged bundle does not look like a drm build (no ' + f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); ' + 'refusing to package it as the unattended-wayland variant, which conflicts with and ' + 'replaces the stock package but could never capture') + # And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and + # documented as the variant that can reach a machine whose screen has gone dark. The dlopen + # marker above does not distinguish them: `--features drm` alone carries it and has no wake code + # at all. Asserting only the first half is how a deb can be named for a feature it does not have. + if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries): + raise Exception( + f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} ' + f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; ' + 'refusing to package it as the unattended-wayland variant, which is named and ' + 'documented as the build that can wake an idle-disabled display. If this fired under ' + '--skip-cargo, the cargo line that produced the bundle is missing the feature: ' + '--features ...,drm,drm-wake') + + +def build_deb_from_folder(version, binary_folder, want_drm=False): os.chdir('flutter') stage_custom_txt_for_linux_bundle(Path('..') / binary_folder) system2('mkdir -p tmpdeb/usr/bin/') @@ -404,9 +861,53 @@ def build_deb_from_folder(version, binary_folder): 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Where the capture library comes from for a `--package --drm` build. Two shapes are + # supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.* + # (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path + # here actually produces -- the flutter deb builds the library straight into the staged deb, so + # nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag + # combination impossible to satisfy. + bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*') + bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob) + # The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be + # staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when + # --drm was never passed. + if bundle_carries_so and not want_drm: + raise Exception( + 'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing ' + 'to silently ship the consent-bypass unattended-wayland variant (pass --drm to ' + 'build it deliberately)') + if want_drm: + # Whichever shape we are in, the staged BINARY must really be a drm build. This is the + # property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as + # the unattended-wayland variant would carry the consent-bypass name, conflict with and + # replace the stock package, and never be able to capture. The marker is the absolute + # dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same + # kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what + # was produced, not what was asked for. + assert_staged_binary_is_drm() + if bundle_carries_so: + so = _single_real_so(bundled_glob, 'the staged --drm bundle') + # The THIRD artifact source, and the last one that was missing the check: --package + # takes the .so straight out of a bundle somebody else produced, so it has the same + # exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub + # would ship, the loader would accept it, and capture would degrade to PipeWire + # without a word. + _assert_so_has_egl(so) + stage_libdrmtap_into_deb(so) + system2(f'rm -f "{so}"') + system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0') + else: + # Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the + # EGL backend itself). The library is independent of the staged binary. + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + # Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has + # its library in tmpdeb whichever of the two shapes it came from. + if want_drm: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -414,6 +915,8 @@ def build_deb_from_folder(version, binary_folder): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if want_drm: + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") @@ -488,6 +991,19 @@ def main(): parser = make_parser() args = parser.parse_args() + # Before anything with a side effect: this is a query, and a caller uses it to build the very + # binary it will then package. `get_features` stays the single definition of what a flag + # combination means; a caller that hardcodes the list instead is one edit away from compiling + # something other than what it ships. + if args.print_features: + # stdout carries the list and nothing else, so a caller can use it directly in a command + # substitution. `get_features` prints a human-readable line of its own; send that to stderr + # for this call rather than silencing it, which would change what every other path prints. + with contextlib.redirect_stdout(sys.stderr): + feats = ','.join(get_features(args)) + print(feats) + return + if os.path.exists(exe_path): os.unlink(exe_path) if os.path.isfile('/usr/bin/pacman'): @@ -503,7 +1019,7 @@ def main(): portable = args.portable package = args.package if package: - build_deb_from_folder(version, package) + build_deb_from_folder(version, package, args.drm) return res_dir = 'resources' external_resources(flutter, args, res_dir) diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index e5db8edef71..2a5cfe0892e 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -107,7 +107,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 31fd632e6d1..43bbf27a6f5 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to RustDesk -RustDesk welcomes contribution from everyone. Here are the guidelines if you are +RustDesk welcomes contributions from everyone. Here are the guidelines if you are thinking of helping us: ## Contributions diff --git a/docs/README-ES.md b/docs/README-ES.md index f46f64b042a..b54a3de7300 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -36,7 +36,7 @@ RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md` ## Dependencias -Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Consulta el [workflow de Windows específico del fork](../.github/workflows/rustqs-windows-min-test.yml) como referencia actual de compilación; el archivo es solo una referencia al código fuente, no una declaración de lanzamiento o soporte. +Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Consulta el [workflow de Windows específico del fork](../.github/workflows/rustqs-windows.yml) como referencia actual de compilación; el archivo es solo una referencia al código fuente, no una declaración de lanzamiento o soporte. Por favor descarga la librería dinámica de Sciter tú mismo. diff --git a/docs/README-IT.md b/docs/README-IT.md index 7535c041dea..3ef01fbab8f 100644 --- a/docs/README-IT.md +++ b/docs/README-IT.md @@ -33,7 +33,7 @@ RustDesk accoglie il contributo di tutti. Per ulteriori informazioni su come ini ## Dipendenze -Le versioni desktop utilizzano Flutter o Sciter (deprecato) per l'interfaccia utente, questo tutorial è solo per Sciter, poiché è più facile per iniziare. Per il riferimento attuale alla compilazione del client in questo fork, consulta il [workflow Windows](../.github/workflows/rustqs-windows-min-test.yml); il file è solo un riferimento al codice sorgente, non una dichiarazione di rilascio o supporto. +Le versioni desktop utilizzano Flutter o Sciter (deprecato) per l'interfaccia utente, questo tutorial è solo per Sciter, poiché è più facile per iniziare. Per il riferimento attuale alla compilazione del client in questo fork, consulta il [workflow Windows](../.github/workflows/rustqs-windows.yml); il file è solo un riferimento al codice sorgente, non una dichiarazione di rilascio o supporto. Scarica la libreria dinamica Sciter. diff --git a/docs/README-JP.md b/docs/README-JP.md index 72ef62c17b8..41c1fb59617 100644 --- a/docs/README-JP.md +++ b/docs/README-JP.md @@ -32,7 +32,7 @@ RustDeskは皆さんの貢献を歓迎します。 ## 依存関係 -デスクトップ版ではGUIにFlutterまたはSciter(非推奨)を使用しますが、チュートリアルでは分かりやすく、簡単なSciterのみを対象に解説しています。この fork の現在のクライアントビルド参照は [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml) です。これはソース参照であり、リリースやサポートの主張ではありません。 +デスクトップ版ではGUIにFlutterまたはSciter(非推奨)を使用しますが、チュートリアルでは分かりやすく、簡単なSciterのみを対象に解説しています。この fork の現在のクライアントビルド参照は [Windows workflow](../.github/workflows/rustqs-windows.yml) です。これはソース参照であり、リリースやサポートの主張ではありません。 Sciter dynamic libraryを事前にダウンロードしてください。 diff --git a/docs/README-KR.md b/docs/README-KR.md index 907df73065c..d7278b1eed5 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -38,7 +38,7 @@ RustDesk는 모든 분들의 기여를 환영합니다. 시작하는 데 도움 ## 종속성 -데스크톱 버전은 GUI로 Flutter 또는 Sciter (더 이상 지원되지 않음)를 사용하며, 이 자습서는 시작하기 더 쉽고 친숙한 Sciter 전용입니다. 이 fork의 현재 클라이언트 빌드 참조는 [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml)입니다. 이 파일은 소스 참조일 뿐 릴리스 또는 지원을 의미하지 않습니다. +데스크톱 버전은 GUI로 Flutter 또는 Sciter (더 이상 지원되지 않음)를 사용하며, 이 자습서는 시작하기 더 쉽고 친숙한 Sciter 전용입니다. 이 fork의 현재 클라이언트 빌드 참조는 [Windows workflow](../.github/workflows/rustqs-windows.yml)입니다. 이 파일은 소스 참조일 뿐 릴리스 또는 지원을 의미하지 않습니다. Sciter 동적 라이브러리를 직접 다운로드하세요. diff --git a/docs/README-NO.md b/docs/README-NO.md index 0744f2e78c6..57d2424a9eb 100644 --- a/docs/README-NO.md +++ b/docs/README-NO.md @@ -34,7 +34,7 @@ RustDesk er velkommen for bidrag fra alle. Se [CONTRIBUTING.md](CONTRIBUTING-NO. ## Avhengigheter -Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet at det er lettere og en mer vennlig start. Se forkets [Windows-arbeidsflyt](../.github/workflows/rustqs-windows-min-test.yml) som gjeldende byggereferanse; filen er bare en kildekodereferanse, ikke en lanserings- eller støtteerklæring. +Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet at det er lettere og en mer vennlig start. Se forkets [Windows-arbeidsflyt](../.github/workflows/rustqs-windows.yml) som gjeldende byggereferanse; filen er bare en kildekodereferanse, ikke en lanserings- eller støtteerklæring. Venligst last ned Sciters dynamiske bibliotek selv. diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index aa923d6183c..cb2d3fd4ab0 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -38,7 +38,7 @@ O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](CONTRIBUTING ## Dependências -As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Consulte o [workflow Windows específico do fork](../.github/workflows/rustqs-windows-min-test.yml) como referência atual de compilação; o arquivo é apenas uma referência de código-fonte, não uma declaração de lançamento ou suporte. +As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Consulte o [workflow Windows específico do fork](../.github/workflows/rustqs-windows.yml) como referência atual de compilação; o arquivo é apenas uma referência de código-fonte, não uma declaração de lançamento ou suporte. Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria. diff --git a/docs/README-RO.md b/docs/README-RO.md index 4c2d2b40798..ec59d69fa9c 100644 --- a/docs/README-RO.md +++ b/docs/README-RO.md @@ -38,7 +38,7 @@ RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CO ## Dependențe -Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [workflow-ul Windows al fork-ului](../.github/workflows/rustqs-windows-min-test.yml) ca referință actuală pentru compilarea clientului; fișierul este doar o referință la sursă, nu o declarație de lansare sau suport. +Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [workflow-ul Windows al fork-ului](../.github/workflows/rustqs-windows.yml) ca referință actuală pentru compilarea clientului; fișierul este doar o referință la sursă, nu o declarație de lansare sau suport. Te rugăm să descarci singur librăria dinamică Sciter. diff --git a/docs/README-RU.md b/docs/README-RU.md index 967ec8ad474..ef34bad4923 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -40,7 +40,7 @@ RustDesk приветствует вклад каждого. Ознакомьт ## Зависимости -Для ПК-версии используются библиотеки Flutter или Sciter (устаревшее) для графического интерфейса. Данное руководство подразумевает работу с Sciter, так как он более простой в использовании и с ним легче начать работу. Текущий справочник сборки клиента в этом fork — [Windows workflow](../.github/workflows/rustqs-windows-min-test.yml); это ссылка на исходный файл, а не заявление о релизе или поддержке. +Для ПК-версии используются библиотеки Flutter или Sciter (устаревшее) для графического интерфейса. Данное руководство подразумевает работу с Sciter, так как он более простой в использовании и с ним легче начать работу. Текущий справочник сборки клиента в этом fork — [Windows workflow](../.github/workflows/rustqs-windows.yml); это ссылка на исходный файл, а не заявление о релизе или поддержке. Загрузите динамическую библиотеку Sciter самостоятельно. diff --git a/docs/README-TR.md b/docs/README-TR.md index c009743cc23..e2691779cfd 100644 --- a/docs/README-TR.md +++ b/docs/README-TR.md @@ -37,7 +37,7 @@ RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CON ## Gereksinimler -Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Bu fork'un güncel istemci derleme referansı için [Windows iş akışına](../.github/workflows/rustqs-windows-min-test.yml) bakın; dosya yalnızca kaynak referansıdır, sürüm veya destek iddiası değildir. +Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Bu fork'un güncel istemci derleme referansı için [Windows iş akışına](../.github/workflows/rustqs-windows.yml) bakın; dosya yalnızca kaynak referansıdır, sürüm veya destek iddiası değildir. Lütfen Sciter dinamik kütüphanesini kendiniz indirin. diff --git a/docs/README-UA.md b/docs/README-UA.md index 05a5b52904d..2149c7998e4 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -31,7 +31,7 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT ## Залежності -Стільничні версії використовують Flutter чи Sciter (застаріле) для графічного інтерфейсу. Ця інструкція лише для Sciter, оскільки він є більш простим та дружнім для початківців. Перегляньте [Windows workflow цього fork](../.github/workflows/rustqs-windows-min-test.yml) як поточний довідник збірки; це посилання на джерело, а не заява про реліз чи підтримку. +Стільничні версії використовують Flutter чи Sciter (застаріле) для графічного інтерфейсу. Ця інструкція лише для Sciter, оскільки він є більш простим та дружнім для початківців. Перегляньте [Windows workflow цього fork](../.github/workflows/rustqs-windows.yml) як поточний довідник збірки; це посилання на джерело, а не заява про реліз чи підтримку. Будь ласка, завантажте динамічну бібліотеку Sciter самостійно. diff --git a/docs/README-VN.md b/docs/README-VN.md index 9b449b2c863..1eba2a78691 100644 --- a/docs/README-VN.md +++ b/docs/README-VN.md @@ -31,7 +31,7 @@ RustDesk là một phần mềm điểu khiển máy tính từ xa mã nguồn m ## Dependencies -Phiên bản máy tính sử dụng __Flutter__ hoặc __Sciter__ (đã lỗi thời) cho giao diện người dùng (GUI). Hướng dẫn này chỉ áp dụng cho phiên bản Sciter, vì nó thân thiện và dễ bắt đầu hơn. Tham khảo [workflow Windows của fork](../.github/workflows/rustqs-windows-min-test.yml) để xem quy trình xây dựng hiện tại; tệp này chỉ là tham chiếu nguồn, không phải tuyên bố phát hành hay hỗ trợ. +Phiên bản máy tính sử dụng __Flutter__ hoặc __Sciter__ (đã lỗi thời) cho giao diện người dùng (GUI). Hướng dẫn này chỉ áp dụng cho phiên bản Sciter, vì nó thân thiện và dễ bắt đầu hơn. Tham khảo [workflow Windows của fork](../.github/workflows/rustqs-windows.yml) để xem quy trình xây dựng hiện tại; tệp này chỉ là tham chiếu nguồn, không phải tuyên bố phát hành hay hỗ trợ. Vui lòng tự tải thư viện `Sciter` về máy theo hướng dẫn cho từng hệ điều hành. diff --git a/docs/README-ZH.md b/docs/README-ZH.md index ed3f493024c..95445e107fd 100644 --- a/docs/README-ZH.md +++ b/docs/README-ZH.md @@ -36,7 +36,7 @@ RustDesk 期待各位的贡献. 如何参与开发? 详情请看 [CONTRIBUTING-Z ## 依赖 -桌面版本使用 Flutter 或 Sciter(已弃用)作为 GUI,本教程仅适用于 Sciter,因为它更简单且更易于上手。此 fork 的当前客户端构建参考位于[Windows 工作流](../.github/workflows/rustqs-windows-min-test.yml);该文件是源代码参考,不代表发布或支持声明。 +桌面版本使用 Flutter 或 Sciter(已弃用)作为 GUI,本教程仅适用于 Sciter,因为它更简单且更易于上手。此 fork 的当前客户端构建参考位于[Windows 工作流](../.github/workflows/rustqs-windows.yml);该文件是源代码参考,不代表发布或支持声明。 请自行下载Sciter动态库。 diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt index 3ca83fbac73..6a4a21bab10 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt @@ -8,6 +8,7 @@ package com.carriez.flutter_hbb import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription +import android.content.Intent import android.graphics.Path import android.os.Build import android.os.Bundle @@ -68,6 +69,16 @@ class InputService : AccessibilityService() { get() = ctx != null } + private fun notifyInputState() { + val inputState = isOpen.toString() + Handler(Looper.getMainLooper()).post { + MainActivity.flutterMethodChannel?.invokeMethod( + "on_state_changed", + mapOf("name" to "input", "value" to inputState) + ) + } + } + private val logTag = "input service" private var leftIsDown = false private var touchPath = Path() @@ -716,6 +727,7 @@ class InputService : AccessibilityService() { override fun onServiceConnected() { super.onServiceConnected() ctx = this + notifyInputState() val info = AccessibilityServiceInfo() if (Build.VERSION.SDK_INT >= 33) { info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS @@ -734,8 +746,16 @@ class InputService : AccessibilityService() { override fun onDestroy() { ctx = null + // Keep this fallback even though onUnbind usually notifies first. + notifyInputState() super.onDestroy() } + override fun onUnbind(intent: Intent?): Boolean { + ctx = null + notifyInputState() + return super.onUnbind(intent) + } + override fun onInterrupt() {} } diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index fea8e5519c9..7274085fd3f 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -106,6 +106,16 @@ class MainActivity : FlutterActivity() { override fun onDestroy() { Log.e(logTag, "onDestroy") + // The process can outlive the UI whenever something keeps it alive: + // MainService, or the accessibility InputService on its own. Only the + // former gets onTaskRemoved, so close outgoing sessions here too, + // otherwise a session survives with no UI left to close it. + // `isFinishing` distinguishes the user really leaving from a destroy + // for recreation (configuration change, "don't keep activities"), + // which must not tear down a live session. + if (isFinishing) { + FFI.closeAllSessions() + } mainService?.let { unbindService(serviceConnection) } @@ -200,12 +210,13 @@ class MainActivity : FlutterActivity() { "stop_input" -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { InputService.ctx?.disableSelf() + } else { + InputService.ctx = null + Companion.flutterMethodChannel?.invokeMethod( + "on_state_changed", + mapOf("name" to "input", "value" to InputService.isOpen.toString()) + ) } - InputService.ctx = null - Companion.flutterMethodChannel?.invokeMethod( - "on_state_changed", - mapOf("name" to "input", "value" to InputService.isOpen.toString()) - ) result.success(true) } "cancel_notification" -> { diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index 0958e5e33e2..abaf6a47fd6 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -279,6 +279,16 @@ class MainService : Service() { super.onDestroy() } + // Swiping the app away from recents destroys the UI but this service keeps + // the process alive, so outgoing sessions would stay connected with no way + // to close them. Incoming connections are unaffected: the service keeps + // running so the device stays reachable. + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(logTag, "onTaskRemoved, closing outgoing sessions") + FFI.closeAllSessions() + super.onTaskRemoved(rootIntent) + } + private var isHalfScale: Boolean? = null; private fun updateScreenInfo(orientation: Int) { var w: Int diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index 6fdce6f71f9..54b65eea746 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -21,6 +21,7 @@ object FFI { external fun onAudioFrameUpdate(buf: ByteBuffer) external fun translateLocale(localeName: String, input: String): String external fun refreshScreen() + external fun closeAllSessions() external fun setFrameRawEnable(name: String, value: Boolean) external fun setCodecInfo(info: String) external fun getLocalOption(key: String): String diff --git a/flutter/build_android_deps.sh b/flutter/build_android_deps.sh index 64fb9dad2a9..1f07cb7ba1f 100755 --- a/flutter/build_android_deps.sh +++ b/flutter/build_android_deps.sh @@ -6,83 +6,82 @@ ANDROID_ABI=$1 # Build RustDesk dependencies for Android using vcpkg.json # Required: -# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables +# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables # 2. vcpkg initialized # 3. ndk, version: r25c or newer -if [ -z "$ANDROID_NDK_HOME" ]; then - echo "Failed! Please set ANDROID_NDK_HOME" - exit 1 +if [ -z "${ANDROID_NDK_HOME}" ]; then + echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2 + exit 1 fi -if [ -z "$VCPKG_ROOT" ]; then - echo "Failed! Please set VCPKG_ROOT" - exit 1 +if [ -z "${VCPKG_ROOT}" ]; then + echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2 + exit 1 fi -API_LEVEL="21" +case "${ANDROID_ABI}" in +arm64-v8a) + VCPKG_TARGET=arm64-android + ;; +armeabi-v7a) + VCPKG_TARGET=arm-neon-android + ;; +x86_64) + VCPKG_TARGET=x64-android + ;; +x86) + VCPKG_TARGET=x86-android + ;; +*) + echo "Usage: build_android_deps.sh " 1>&2 + exit 1 + ;; +esac # Get directory of this script SCRIPTDIR="$(readlink -f "$0")" -SCRIPTDIR="$(dirname "$SCRIPTDIR")" +SCRIPTDIR="$(dirname "${SCRIPTDIR}")" # Check if vcpkg.json is one level up - in root directory of RD -if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then - echo "Failed! Please check where vcpkg.json is!" - exit 1 +if [ ! -f "${SCRIPTDIR}/../vcpkg.json" ]; then + echo "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2 + exit 1 fi -# NDK llvm toolchain - -HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/` -TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG - -function build { - ANDROID_ABI=$1 - - case "$ANDROID_ABI" in - arm64-v8a) - ABI=aarch64-linux-android$API_LEVEL - VCPKG_TARGET=arm64-android - ;; - armeabi-v7a) - ABI=armv7a-linux-androideabi$API_LEVEL - VCPKG_TARGET=arm-neon-android - ;; - x86_64) - ABI=x86_64-linux-android$API_LEVEL - VCPKG_TARGET=x64-android - ;; - x86) - ABI=i686-linux-android$API_LEVEL - VCPKG_TARGET=x86-android - ;; - *) - echo "ERROR: ANDROID_ABI must be one of: arm64-v8a, armeabi-v7a, x86_64, x86" >&2 - return 1 - esac - - echo "*** [$ANDROID_ABI][Start] Build and install vcpkg dependencies" - pushd "$SCRIPTDIR/.." - $VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed" - popd - head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true - echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies" - -if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then - echo "*** [Start] Move arm-neon-android to arm-android" - - mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android" - - echo "*** [Finished] Move arm-neon-android to arm-android" -fi -} +echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..." + +pushd "${SCRIPTDIR}/.." + +"${VCPKG_ROOT}/vcpkg" install \ + --triplet "${VCPKG_TARGET}" \ + --x-install-root="${VCPKG_ROOT}/installed" + +popd + +echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}" + +if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then + # Symlink arm-neon-android to arm-android because cargo-ndk does not + # understand NEON suffix. + + if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then + echo 'INFO: Symlinking arm-neon-android to arm-android' + + ln -sf \ + "${VCPKG_ROOT}/installed/arm-neon-android" \ + "${VCPKG_ROOT}/installed/arm-android" + + echo 'INFO: Symlinked arm-neon-android to arm-android' + else + cat 0<<.a +ERROR: 'vcpkg install' seem to complete successfully but +directory '${VCPKG_ROOT}/installed/arm-neon-android' is missing! + +.a -if [ ! -z "$ANDROID_ABI" ]; then - build "$ANDROID_ABI" -else - echo "Usage: build-android-deps.sh " >&2 - exit 1 + exit 1 + fi fi diff --git a/flutter/build_fdroid.sh b/flutter/build_fdroid.sh index e26bc4c63b6..4a3f121ecb5 100755 --- a/flutter/build_fdroid.sh +++ b/flutter/build_fdroid.sh @@ -3,10 +3,7 @@ # # Script to build F-Droid release of RustDesk # -# LEGACY: this F-Droid builder still reads the historical -# .github/workflows/flutter-build.yml, which is absent from the current fork. It is -# not part of the active rustqs workflow path; the historical references below are -# intentionally not treated as current workflow configuration. +# This F-Droid builder reads version settings from the active rustqs Android workflow. # # Copyright (C) 2024, The RustDesk Authors # 2024, Vasyl Gello @@ -133,23 +130,23 @@ prebuild) # # Extract required versions for NDK, Rust, Flutter from - # '.github/workflows/flutter-build.yml' + # '.github/workflows/rustqs-android.yml' # CARGO_NDK_VERSION="$(yq -r \ .env.CARGO_NDK_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" # Flutter used to compile main Rustdesk library FLUTTER_VERSION="$(yq -r \ - .env.ANDROID_FLUTTER_VERSION \ - .github/workflows/flutter-build.yml)" + '.env.ANDROID_FLUTTER_VERSION // ""' \ + .github/workflows/rustqs-android.yml)" if [ -z "${FLUTTER_VERSION}" ]; then FLUTTER_VERSION="$(yq -r \ .env.FLUTTER_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" fi # Flutter used to compile Flutter<->Rust bridge files @@ -168,15 +165,15 @@ prebuild) NDK_VERSION="$(yq -r \ .env.NDK_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" RUST_VERSION="$(yq -r \ .env.RUST_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" VCPKG_COMMIT_ID="$(yq -r \ .env.VCPKG_COMMIT_ID \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" if [ -z "${CARGO_NDK_VERSION}" ] || [ -z "${FLUTTER_VERSION}" ] || [ -z "${FLUTTER_BRIDGE_VERSION}" ] || @@ -409,24 +406,24 @@ build) # # Extract required versions for NDK, Rust, Flutter from - # '.github/workflows/flutter-build.yml' + # '.github/workflows/rustqs-android.yml' # # Flutter used to compile main Rustdesk library FLUTTER_VERSION="$(yq -r \ - .env.ANDROID_FLUTTER_VERSION \ - .github/workflows/flutter-build.yml)" + '.env.ANDROID_FLUTTER_VERSION // ""' \ + .github/workflows/rustqs-android.yml)" if [ -z "${FLUTTER_VERSION}" ]; then FLUTTER_VERSION="$(yq -r \ .env.FLUTTER_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" fi NDK_VERSION="$(yq -r \ .env.NDK_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/rustqs-android.yml)" # Map NDK version to revision NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json | diff --git a/flutter/ios/Runner/GoogleService-Info.plist b/flutter/ios/Runner/GoogleService-Info.plist deleted file mode 100644 index f392882309d..00000000000 --- a/flutter/ios/Runner/GoogleService-Info.plist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - CLIENT_ID - 768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com - REVERSED_CLIENT_ID - com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn - API_KEY - AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc - GCM_SENDER_ID - 768133699366 - PLIST_VERSION - 1 - BUNDLE_ID - com.carriez.flutterHbb - PROJECT_ID - rustdesk - STORAGE_BUCKET - rustdesk.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:768133699366:ios:c33078a6181b9d507993e7 - DATABASE_URL - https://rustdesk.firebaseio.com - - \ No newline at end of file diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 58ddc0cb05d..94c3c2a72b8 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -1185,6 +1185,48 @@ void msgBox(SessionID sessionId, String type, String title, String text, VoidCallback? onSubmit, int? submitTimeout}) { dialogManager.dismissAll(); + if (type.contains('insecure-connection')) { + Future closeSession() async { + await bind.sessionSetCommon( + sessionId: sessionId, + key: 'continue-insecure-connection', + value: 'N', + ); + dialogManager.dismissAll(); + closeConnection(); + } + + void continueSession() { + unawaited( + bind.sessionSetCommon( + sessionId: sessionId, + key: 'continue-insecure-connection', + value: 'Y', + ), + ); + dialogManager.dismissAll(); + } + + dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: null, + content: SelectionArea(child: msgboxContent(type, title, text)), + actions: [ + dialogButton( + 'Continue', + onPressed: continueSession, + isOutline: true, + ), + dialogButton('Disconnect', onPressed: closeSession), + ], + onSubmit: closeSession, + onCancel: closeSession, + ), + tag: '$sessionId-$type-$title-$text-$link', + ); + return; + } + List buttons = []; bool hasOk = false; submit() { @@ -3082,6 +3124,15 @@ void onCopyFingerprint(String value) { } } +void onCopyId(String value) { + if (value.isNotEmpty) { + Clipboard.setData(ClipboardData(text: value)); + showToast('$value\n${translate("Copied")}'); + } else { + showToast(translate("Invalid ID")); + } +} + Future callMainCheckSuperUserPermission() async { bool checked = await bind.mainCheckSuperUserPermission(); if (isMacOS) { @@ -3350,7 +3401,12 @@ Future> getScreenRectList() async { } openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi, - {bool updateCursorPos = true}) { + {bool updateCursorPos = true, bool recordSelection = true}) { + if (recordSelection) { + ffi.ffiModel.lastUserDisplay = i; + ffi.ffiModel.cancelPendingRestoreTimer(); + ffi.ffiModel.pendingMonitorRestore = null; + } final displays = i == kAllDisplayValue ? List.generate(pi.displays.length, (index) => index) : [i]; @@ -3957,6 +4013,11 @@ bool whitelistNotEmpty() { return v != '' && v != ','; } +bool idWhitelistNotEmpty() { + final v = bind.mainGetOptionSync(key: kOptionIdWhitelist); + return v != '' && v != ','; +} + // `setMovable()` is only supported on macOS. // // On macOS, the window can be dragged by the tab bar by default. @@ -3987,7 +4048,8 @@ Widget netWorkErrorWidget() { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(translate("network_error_tip")), + if (!gFFI.userModel.networkErrorFromServer.value) + Text(translate("network_error_tip")), ElevatedButton( onPressed: gFFI.userModel.refreshCurrentUser, child: Text(translate("Retry"))) diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index 7534fb2a1f5..f8060380285 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -205,6 +205,10 @@ void changeWhiteList({Function()? callback}) async { const SizedBox( height: 8.0, ), + Text(translate("whitelist_cidr_tip")), + const SizedBox( + height: 8.0, + ), Row( children: [ Expanded( @@ -282,6 +286,111 @@ void changeWhiteList({Function()? callback}) async { }); } +void changeIdWhiteList({Function()? callback}) async { + final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist); + var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist + ? '' + : curIdWhiteList.split(',').join('\n'); + var controller = TextEditingController(text: newIdWhiteListField); + var msg = ""; + var isInProgress = false; + final isOptFixed = isOptionFixed(kOptionIdWhitelist); + gFFI.dialogManager.show((setState, close, context) { + return CustomAlertDialog( + title: Text(translate("ID whitelisting")), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(translate("whitelist_sep")), + const SizedBox( + height: 8.0, + ), + Text(translate("id_whitelist_wildcard_tip")), + const SizedBox( + height: 8.0, + ), + Text(translate("id_whitelist_caveat_tip")), + const SizedBox( + height: 8.0, + ), + Row( + children: [ + Expanded( + child: TextField( + maxLines: null, + decoration: InputDecoration( + errorText: msg.isEmpty ? null : translate(msg), + ), + controller: controller, + enabled: !isOptFixed, + autofocus: true) + .workaroundFreezeLinuxMint(), + ), + ], + ), + const SizedBox( + height: 4.0, + ), + // NOT use Offstage to wrap LinearProgressIndicator + if (isInProgress) const LinearProgressIndicator(), + ], + ), + actions: [ + dialogButton("Cancel", onPressed: close, isOutline: true), + if (!isOptFixed) + dialogButton("Clear", onPressed: () async { + await bind.mainSetOption( + key: kOptionIdWhitelist, value: defaultOptionWhitelist); + callback?.call(); + close(); + }, isOutline: true), + if (!isOptFixed) + dialogButton( + "OK", + onPressed: () async { + setState(() { + msg = ""; + isInProgress = true; + }); + newIdWhiteListField = controller.text.trim(); + var newIdWhiteList = ""; + if (newIdWhiteListField.isEmpty) { + // pass + } else { + final ids = newIdWhiteListField + .trim() + .split(RegExp(r"[\s,;\n]+")) + .where((e) => e.isNotEmpty) + .toList(); + // Separators are handled above; allow all other Unicode characters. + for (final id in ids) { + final hasControlCharacters = id.runes.any( + (char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f)); + if (hasControlCharacters) { + msg = "${translate("Invalid ID")} $id"; + setState(() { + isInProgress = false; + }); + return; + } + } + newIdWhiteList = ids.join(','); + } + if (newIdWhiteList.trim().isEmpty) { + newIdWhiteList = defaultOptionWhitelist; + } + await bind.mainSetOption( + key: kOptionIdWhitelist, value: newIdWhiteList); + callback?.call(); + close(); + }, + ), + ], + onCancel: close, + ); + }); +} + Future changeDirectAccessPort( String currentIP, String currentPort) async { final controller = TextEditingController(text: currentPort); diff --git a/flutter/lib/common/widgets/login.dart b/flutter/lib/common/widgets/login.dart index 31917189582..826949456d4 100644 --- a/flutter/lib/common/widgets/login.dart +++ b/flutter/lib/common/widgets/login.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/hbbs/hbbs.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/user_model.dart'; @@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../common.dart'; import './dialog.dart'; +import './oidc_auth_status.dart'; const kOpSvgList = [ 'github', @@ -23,6 +25,8 @@ const kOpSvgList = [ 'auth0', 'microsoft' ]; +const _requestingAccountAuth = 'Requesting account auth'; +const _waitingAccountAuth = 'Waiting account auth'; class _OidcProviderBranding { final String label; @@ -90,6 +94,7 @@ class ButtonOP extends StatelessWidget { final Color primaryColor; final double height; final Function() onTap; + final bool Function() canStartAuth; const ButtonOP({ Key? key, @@ -99,6 +104,7 @@ class ButtonOP extends StatelessWidget { required this.primaryColor, required this.height, required this.onTap, + required this.canStartAuth, }) : super(key: key); @override @@ -111,11 +117,10 @@ class ButtonOP extends StatelessWidget { width: 200, child: Obx(() => ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: curOP.value.isEmpty || curOP.value == op - ? primaryColor - : Colors.grey, + backgroundColor: primaryColor, ).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)), - onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null, + onPressed: + curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap, child: Row( children: [ SizedBox( @@ -145,15 +150,120 @@ class ConfigOP { ConfigOP({required this.op, required this.icon}); } +class _OidcAuthController { + final RxString curOP = ''.obs; + Future _pendingOperation = Future.value(); + int _authAttempt = 0; + bool _closed = false; + final _cancelInProgress = false.obs; + + bool _isCurrent(int authAttempt, String op) { + return !_closed && authAttempt == _authAttempt && curOP.value == op; + } + + Future start(String op) { + if (!canStart()) { + return Future.value(false); + } + final authAttempt = ++_authAttempt; + curOP.value = op; + // Web auth must start during the original user gesture so popups are allowed. + if (isWeb) { + return _startWeb(authAttempt, op); + } + final completer = Completer(); + _pendingOperation = _pendingOperation.then((_) async { + if (!_isCurrent(authAttempt, op)) { + completer.complete(false); + return; + } + try { + await bind.mainAccountAuthCancel(); + if (!_isCurrent(authAttempt, op)) { + completer.complete(false); + return; + } + await bind.mainAccountAuth(op: op, rememberMe: true); + completer.complete(_isCurrent(authAttempt, op)); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + Future _startWeb(int authAttempt, String op) async { + await bind.mainAccountAuth(op: op, rememberMe: true); + return _isCurrent(authAttempt, op); + } + + bool canStart() { + return !_closed && !_cancelInProgress.value; + } + + Future cancelCurrent(String op) { + if (!canStart() || curOP.value != op) { + return Future.value(false); + } + final authAttempt = ++_authAttempt; + final completer = Completer(); + _cancelInProgress.value = true; + _pendingOperation = _pendingOperation.then((_) async { + try { + await bind.mainAccountAuthCancel(); + completer.complete(_isCurrent(authAttempt, op)); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + _cancelInProgress.value = false; + } + }); + return completer.future; + } + + Future _cancelBackend() async { + try { + await bind.mainAccountAuthCancel(); + } catch (error, stackTrace) { + debugPrint('Failed to cancel account authentication $error'); + debugPrintStack(stackTrace: stackTrace); + } + } + + Future close() async { + if (_closed) { + return; + } + final hasActiveOidcAuth = + curOP.value.isNotEmpty && curOP.value != 'rustdesk'; + _closed = true; + _authAttempt++; + curOP.value = ''; + if (hasActiveOidcAuth) { + await _cancelBackend(); + } + await _pendingOperation; + if (hasActiveOidcAuth) { + await _cancelBackend(); + } + } +} + class WidgetOP extends StatefulWidget { final ConfigOP config; final RxString curOP; final Function(Map) cbLogin; + final Future Function(String) startAuth; + final Future Function(String) cancelAuth; + final bool Function() canStartAuth; const WidgetOP({ Key? key, required this.config, required this.curOP, required this.cbLogin, + required this.startAuth, + required this.cancelAuth, + required this.canStartAuth, }) : super(key: key); @override @@ -164,6 +274,8 @@ class WidgetOP extends StatefulWidget { class _WidgetOPState extends State { Timer? _updateTimer; + bool _isAuthStatusQueryInFlight = false; + int _authAttempt = 0; String _stateMsg = ''; String _failedMsg = ''; String _url = ''; @@ -174,55 +286,180 @@ class _WidgetOPState extends State { _updateTimer?.cancel(); } - _beginQueryState() { + _beginQueryState(int authAttempt) { + _updateTimer?.cancel(); + unawaited(_runAuthStatusQuery(() => _updateState(authAttempt))); _updateTimer = Timer.periodic(Duration(seconds: 1), (timer) { - _updateState(); + unawaited(_runAuthStatusQuery(() => _updateState(authAttempt))); + }); + } + + Future _runAuthStatusQuery(Future Function() query) async { + if (_isAuthStatusQueryInFlight) { + return; + } + _isAuthStatusQueryInFlight = true; + try { + await query(); + } finally { + _isAuthStatusQueryInFlight = false; + } + } + + Future _launchAuthUrl(String url) async { + try { + final launched = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + if (!launched) { + debugPrint('Failed to open OIDC authentication URL'); + } + } catch (error, stackTrace) { + debugPrint( + 'Failed to open OIDC authentication URL (${error.runtimeType})'); + debugPrintStack(stackTrace: stackTrace); + } + } + + Future _copyAuthUrl(String url) async { + try { + await Clipboard.setData(ClipboardData(text: url)); + showToast( + translate('Copied'), + ); + } catch (error, stackTrace) { + debugPrint( + 'Failed to copy OIDC authentication URL (${error.runtimeType})'); + debugPrintStack(stackTrace: stackTrace); + showToast(translate('Failed')); + } + } + + void _runCurrentAuthUrlAction( + int authAttempt, + String authUrl, + Future Function(String) action, + ) { + if (!mounted || + authAttempt != _authAttempt || + widget.curOP.value != widget.config.op || + authUrl.isEmpty || + _url != authUrl) { + return; + } + unawaited(action(authUrl)); + } + + void _invalidateAuthAttempt() { + _authAttempt++; + _url = ''; + } + + bool _isCurrentAuthAttempt(int authAttempt) { + return mounted && + authAttempt == _authAttempt && + widget.curOP.value == widget.config.op; + } + + Future _handleAuthFailure( + int authAttempt, + Object error, + String operation, + ) async { + debugPrint('Failed to $operation $error'); + if (!_isCurrentAuthAttempt(authAttempt)) { + return; + } + _updateTimer?.cancel(); + setState(() => _failedMsg = 'Failed'); + try { + final canceled = await widget.cancelAuth(widget.config.op); + if (!canceled || !_isCurrentAuthAttempt(authAttempt)) { + return; + } + } catch (cancelError, stackTrace) { + debugPrint('Failed to cancel account authentication $cancelError'); + debugPrintStack(stackTrace: stackTrace); + return; + } + setState(() { + _invalidateAuthAttempt(); + widget.curOP.value = ''; }); } - _updateState() { - bind.mainAccountAuthResult().then((result) { - if (result.isEmpty) { + Future _updateState(int authAttempt) { + if (!mounted || + authAttempt != _authAttempt || + widget.curOP.value != widget.config.op) { + _updateTimer?.cancel(); + return Future.value(); + } + return bind.mainAccountAuthResult().then((result) { + if (!mounted || + authAttempt != _authAttempt || + widget.curOP.value != widget.config.op || + result.isEmpty) { return; } final resultMap = jsonDecode(result); if (resultMap == null) { return; } - final String stateMsg = resultMap['state_msg']; + final String backendStateMsg = resultMap['state_msg']; String failedMsg = resultMap['failed_msg']; final String? url = resultMap['url']; + final stateMsg = backendStateMsg == _requestingAccountAuth && + (url == null || url.isEmpty) + ? _waitingAccountAuth + : backendStateMsg; final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false; final authBody = resultMap['auth_body']; - if (_stateMsg != stateMsg || _failedMsg != failedMsg) { - if (_url.isEmpty && url != null && url.isNotEmpty) { - if (!urlLaunched) { - launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - _url = url; + if (authBody != null) { + _updateTimer?.cancel(); + _invalidateAuthAttempt(); + widget.curOP.value = ''; + widget.cbLogin(authBody as Map); + return; + } + final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg; + final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null; + if (!stateChanged && newUrl == null) { + return; + } + setState(() { + _stateMsg = stateMsg; + _failedMsg = failedMsg; + if (newUrl != null) { + _url = newUrl; } - if (authBody != null) { - _updateTimer?.cancel(); + if (failedMsg.isNotEmpty) { + _invalidateAuthAttempt(); widget.curOP.value = ''; - widget.cbLogin(authBody as Map); + _updateTimer?.cancel(); } - - setState(() { - _stateMsg = stateMsg; - _failedMsg = failedMsg; - if (failedMsg.isNotEmpty) { - widget.curOP.value = ''; - _updateTimer?.cancel(); - } - }); + }); + if (newUrl != null && failedMsg.isEmpty && !urlLaunched) { + unawaited(_launchAuthUrl(newUrl)); } - }); + }).catchError( + (e) => _handleAuthFailure( + authAttempt, + e, + 'query account authentication', + ), + ); } - _resetState() { - _stateMsg = ''; - _failedMsg = ''; - _url = ''; + int _resetState() { + _updateTimer?.cancel(); + setState(() { + _invalidateAuthAttempt(); + _stateMsg = _waitingAccountAuth; + _failedMsg = ''; + }); + return _authAttempt; } @override @@ -235,11 +472,31 @@ class _WidgetOPState extends State { icon: widget.config.icon, primaryColor: str2color(widget.config.op, 0x7f), height: 36, + canStartAuth: widget.canStartAuth, onTap: () async { - _resetState(); - widget.curOP.value = widget.config.op; - await bind.mainAccountAuth(op: widget.config.op, rememberMe: true); - _beginQueryState(); + if (!widget.canStartAuth()) { + return; + } + final authAttempt = _resetState(); + try { + final started = await widget.startAuth(widget.config.op); + if (!started) { + return; + } + } catch (e) { + await _handleAuthFailure( + authAttempt, + e, + 'start account authentication', + ); + return; + } + if (!mounted || + authAttempt != _authAttempt || + widget.curOP.value != widget.config.op) { + return; + } + _beginQueryState(authAttempt); }, ), Obx(() { @@ -247,6 +504,8 @@ class _WidgetOPState extends State { widget.curOP.value != widget.config.op) { _failedMsg = ''; } + final authAttempt = _authAttempt; + final authUrl = _url; return Offstage( offstage: _failedMsg.isEmpty && widget.curOP.value != widget.config.op, @@ -256,19 +515,27 @@ class _WidgetOPState extends State { if (_stateMsg.isNotEmpty && _failedMsg.isEmpty) Padding( padding: const EdgeInsets.only(top: 8.0), - child: SelectableText( - translate(_stateMsg), - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: 12), + child: OidcAuthStatus( + message: translate(_stateMsg), + browserFallbackPrompt: translate( + "Browser didn't open? Use the url below to sign in.", + ), + authUrl: authUrl, + copyLabel: translate('Copy to clipboard'), + onCopy: authUrl.isEmpty + ? null + : () => _runCurrentAuthUrlAction( + authAttempt, + authUrl, + _copyAuthUrl, + ), ), ), if (_failedMsg.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8.0), child: Builder(builder: (context) { - final errorColor = - Theme.of(context).colorScheme.error; + final errorColor = Theme.of(context).colorScheme.error; final bgColor = Theme.of(context) .colorScheme .errorContainer @@ -289,12 +556,11 @@ class _WidgetOPState extends State { Flexible( child: SelectableText( translate(_failedMsg), - style: DefaultTextStyle.of(context) - .style - .copyWith( - fontSize: 13, - color: errorColor, - ), + style: + DefaultTextStyle.of(context).style.copyWith( + fontSize: 13, + color: errorColor, + ), ), ), ], @@ -306,34 +572,6 @@ class _WidgetOPState extends State { ), ); }), - Obx( - () => Offstage( - offstage: widget.curOP.value != widget.config.op, - child: const SizedBox( - height: 5.0, - ), - ), - ), - Obx( - () => Offstage( - offstage: widget.curOP.value != widget.config.op, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: 20), - child: ElevatedButton( - onPressed: () { - widget.curOP.value = ''; - _updateTimer?.cancel(); - _resetState(); - bind.mainAccountAuthCancel(); - }, - child: Text( - translate('Cancel'), - style: TextStyle(fontSize: 15), - ), - ), - ), - ), - ), ], ); } @@ -343,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget { final List ops; final RxString curOP; final Function(Map) cbLogin; + final Future Function(String) startAuth; + final Future Function(String) cancelAuth; + final bool Function() canStartAuth; LoginWidgetOP({ Key? key, required this.ops, required this.curOP, required this.cbLogin, + required this.startAuth, + required this.cancelAuth, + required this.canStartAuth, }) : super(key: key); @override @@ -359,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget { config: op, curOP: curOP, cbLogin: cbLogin, + startAuth: startAuth, + cancelAuth: cancelAuth, + canStartAuth: canStartAuth, ), const Divider( indent: 5, @@ -436,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget { translate('Login'), style: TextStyle(fontSize: 16), ), - onPressed: - curOP.value.isEmpty || curOP.value == 'rustdesk' - ? () { - onLogin(); - } - : null, + onPressed: curOP.value.isEmpty && !isInProgress + ? () { + onLogin(); + } + : null, )), ), ])), @@ -452,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget { const kAuthReqTypeOidc = 'oidc/'; +Future? _activeLoginDialog; + // call this directly -Future loginDialog() async { +Future loginDialog() { + final activeDialog = _activeLoginDialog; + if (activeDialog != null) { + return activeDialog; + } + final dialog = _openLoginDialogOnce(); + _activeLoginDialog = dialog; + return dialog; +} + +Future _openLoginDialogOnce() async { + try { + return await _openLoginDialog(); + } finally { + _activeLoginDialog = null; + } +} + +Future _openLoginDialog() async { var username = TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? ''); var password = TextEditingController(); @@ -463,14 +729,28 @@ Future loginDialog() async { String? usernameMsg; String? passwordMsg; var isInProgress = false; - final RxString curOP = ''.obs; + final oidcAuth = _OidcAuthController(); + final curOP = oidcAuth.curOP; // Track hover state for the close icon bool isCloseHovered = false; final loginOptions = [].obs; - Future.delayed(Duration.zero, () async { - loginOptions.value = await UserModel.queryOidcLoginOptions(); - }); + final loginOptionsError = Rxn(); + final loginOptionsInProgress = false.obs; + fetchLoginOptions() async { + loginOptionsInProgress.value = true; + try { + loginOptions.value = await UserModel.queryOidcLoginOptions(); + loginOptionsError.value = null; + } catch (e) { + debugPrint("queryOidcLoginOptions failed: $e"); + loginOptionsError.value = e; + } finally { + loginOptionsInProgress.value = false; + } + } + + Future.delayed(Duration.zero, fetchLoginOptions); final res = await gFFI.dialogManager.show((setState, close, context) { username.addListener(() { @@ -544,6 +824,9 @@ Future loginDialog() async { } onLogin() async { + if (curOP.value.isNotEmpty || isInProgress) { + return; + } // validate if (username.text.isEmpty) { setState(() => usernameMsg = translate('Username missed')); @@ -574,6 +857,36 @@ Future loginDialog() async { } thirdAuthWidget() => Obx(() { + final error = loginOptionsError.value; + final inProgress = loginOptionsInProgress.value; + if (error != null) { + return Column( + children: [ + const SizedBox(height: 8.0), + // NOT use Offstage to wrap LinearProgressIndicator + if (inProgress) const LinearProgressIndicator(), + if (!inProgress && error is! RequestException) + Text( + translate('network_error_tip'), + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + onPressed: inProgress ? null : fetchLoginOptions, + child: Text(translate('Retry')), + ), + if (!inProgress) + SelectableText( + error.toString(), + style: const TextStyle(fontSize: 11, color: Colors.red), + textAlign: TextAlign.center, + ), + ], + ); + } return Offstage( offstage: loginOptions.isEmpty, child: Column( @@ -594,6 +907,9 @@ Future loginDialog() async { .map((e) => ConfigOP(op: e['name'], icon: e['icon'])) .toList(), curOP: curOP, + startAuth: oidcAuth.start, + cancelAuth: oidcAuth.cancelCurrent, + canStartAuth: oidcAuth.canStart, cbLogin: (Map authBody) async { LoginResponse? resp; try { @@ -675,7 +991,7 @@ Future loginDialog() async { onCancel: onDialogCancel, onSubmit: onLogin, ); - }); + }).whenComplete(oidcAuth.close); if (res != null) { await UserModel.updateOtherModels(); diff --git a/flutter/lib/common/widgets/oidc_auth_status.dart b/flutter/lib/common/widgets/oidc_auth_status.dart new file mode 100644 index 00000000000..d351e81bea3 --- /dev/null +++ b/flutter/lib/common/widgets/oidc_auth_status.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +const _statusFontSize = 12.0; +const _statusSpacing = 4.0; +const _messageActionSpacing = 8.0; +const _desktopActionSize = 28.0; +const _touchPlatforms = { + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.fuchsia, +}; + +class OidcAuthStatus extends StatelessWidget { + final String message; + final String browserFallbackPrompt; + final String authUrl; + final String copyLabel; + final VoidCallback? onCopy; + + const OidcAuthStatus({ + super.key, + required this.message, + required this.browserFallbackPrompt, + required this.authUrl, + required this.copyLabel, + this.onCopy, + }); + + @override + Widget build(BuildContext context) { + final messageStyle = + DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText(message, style: messageStyle), + if (authUrl.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: _messageActionSpacing), + child: _OidcAuthFallback( + browserFallbackPrompt: browserFallbackPrompt, + authUrl: authUrl, + copyLabel: copyLabel, + onCopy: onCopy, + ), + ), + ], + ); + } +} + +class _OidcAuthFallback extends StatefulWidget { + final String browserFallbackPrompt; + final String authUrl; + final String copyLabel; + final VoidCallback? onCopy; + + const _OidcAuthFallback({ + required this.browserFallbackPrompt, + required this.authUrl, + required this.copyLabel, + required this.onCopy, + }); + + @override + State<_OidcAuthFallback> createState() => _OidcAuthFallbackState(); +} + +class _OidcAuthFallbackState extends State<_OidcAuthFallback> { + bool _expanded = false; + + @override + void didUpdateWidget(covariant _OidcAuthFallback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.authUrl != widget.authUrl) { + _expanded = false; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final helperStyle = DefaultTextStyle.of(context).style.copyWith( + fontSize: _statusFontSize, + color: theme.colorScheme.onSurfaceVariant, + ); + final linkColor = theme.brightness == Brightness.dark + ? Colors.blue.shade300 + : Colors.blue.shade800; + final isTouchPlatform = _touchPlatforms.contains(theme.platform); + final actionSize = + isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize; + final urlStyle = + DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.browserFallbackPrompt, + style: helperStyle, + textAlign: TextAlign.center, + ), + Padding( + padding: const EdgeInsets.only(top: _statusSpacing), + child: _buildUrl(urlStyle, linkColor, actionSize), + ), + ], + ); + } + + void _copyAndExpand() { + setState(() => _expanded = true); + widget.onCopy?.call(); + } + + Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) { + final collapsedUrl = SizedBox( + width: double.infinity, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: linkColor, + minimumSize: Size(0, actionSize), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + ), + onPressed: _copyAndExpand, + child: Text( + widget.authUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: urlStyle.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + ), + ), + ), + ); + final collapsedChild = widget.onCopy == null + ? collapsedUrl + : Tooltip(message: widget.copyLabel, child: collapsedUrl); + return Container( + width: double.infinity, + constraints: BoxConstraints(minHeight: actionSize), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(_statusSpacing), + ), + child: _expanded + ? SelectableText(widget.authUrl, style: urlStyle) + : collapsedChild, + ); + } +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 9653b547823..0e4c5b7a579 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -583,6 +583,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { } // record if (!(isDesktop || isWeb) && + bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' && (ffi.recordingModel.start || (perms["recording"] != false))) { v.add(TTextMenu( child: Row( @@ -606,7 +607,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { // to-do: // 1. Web desktop // 2. Mobile, copy the image to the clipboard - if (isDesktop) { + if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) { final isScreenshotSupported = bind.sessionGetCommonSync( sessionId: sessionId, key: 'is_screenshot_supported', param: ''); if ('true' == isScreenshotSupported) { diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 69f4be59ea9..6c22057f95f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -95,6 +95,7 @@ const String kOptionForceAlwaysRelay = "force-always-relay"; const String kOptionViewOnly = "view_only"; const String kOptionEnableLanDiscovery = "enable-lan-discovery"; const String kOptionWhitelist = "whitelist"; +const String kOptionIdWhitelist = "id-whitelist"; const String kOptionEnableAbr = "enable-abr"; const String kOptionEnableRecordSession = "enable-record-session"; const String kOptionDirectServer = "direct-server"; @@ -104,6 +105,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout"; const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; +const String kOptionHideRecordingButton = "hide-recording-button"; const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionAccessMode = "access-mode"; const String kOptionEnableKeyboard = "enable-keyboard"; @@ -177,6 +179,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar"; const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; +const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys"; // network options const String kOptionAllowWebSocket = "allow-websocket"; diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 42ec1003237..76d4641984d 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; -import 'package:flutter_hbb/plugin/ui_manager.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:get/get.dart'; @@ -111,7 +110,6 @@ class _DesktopHomePageState extends State } }, ), - buildPluginEntry(), ]; if (isIncomingOnly) { children.addAll([ @@ -890,21 +888,6 @@ class _DesktopHomePageState extends State shouldBeBlocked(_block, canBeBlocked); } } - - Widget buildPluginEntry() { - final entries = PluginUiManager.instance.entries.entries; - return Offstage( - offstage: entries.isEmpty, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...entries.map((entry) { - return entry.value; - }) - ], - ), - ); - } } void setPasswordDialog({VoidCallback? notEmptyCallback}) async { diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 611e21d004d..a67facfa974 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; -import 'package:flutter_hbb/plugin/manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -55,7 +53,6 @@ enum SettingsTabKey { safety, network, display, - plugin, account, printer, about, @@ -74,8 +71,6 @@ class DesktopSettingPage extends StatefulWidget { bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y') SettingsTabKey.network, if (!bind.isIncomingOnly()) SettingsTabKey.display, - if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) - SettingsTabKey.plugin, if (!bind.isDisableAccount()) SettingsTabKey.account, if (isWindows && bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') @@ -95,7 +90,8 @@ class DesktopSettingPage extends StatefulWidget { if (index == -1) { return; } - if (Get.isRegistered(tag: _kSettingPageControllerTag)) { + if (Get.isRegistered(tag: _kSettingPageControllerTag) && + Get.isRegistered>(tag: _kSettingPageTabKeyTag)) { DesktopTabPage.onAddSetting(initialPage: page); PageController controller = Get.find(tag: _kSettingPageControllerTag); @@ -163,17 +159,23 @@ class _DesktopSettingPageState extends State if (!mounted) { return; } - _canBeBlocked.value = await canBeBlocked(); + final blocked = await canBeBlocked(); + if (!mounted) { + return; + } + _canBeBlocked.value = blocked; }); } @override void dispose() { - super.dispose(); - Get.delete(tag: _kSettingPageControllerTag); - Get.delete(tag: _kSettingPageTabKeyTag); - WidgetsBinding.instance.removeObserver(this); _videoConnTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + Get.delete(tag: _kSettingPageControllerTag); + Get.delete>(tag: _kSettingPageTabKeyTag); + // Get.delete does not dispose a plain ChangeNotifier. + controller.dispose(); + super.dispose(); } List<_TabInfo> _settingTabs() { @@ -196,10 +198,6 @@ class _DesktopSettingPageState extends State settingTabs.add(_TabInfo(tab, 'Display', Icons.desktop_windows_outlined, Icons.desktop_windows)); break; - case SettingsTabKey.plugin: - settingTabs.add(_TabInfo( - tab, 'Plugin', Icons.extension_outlined, Icons.extension)); - break; case SettingsTabKey.account: settingTabs.add( _TabInfo(tab, 'Account', Icons.person_outline, Icons.person)); @@ -233,9 +231,6 @@ class _DesktopSettingPageState extends State case SettingsTabKey.display: children.add(const _Display()); break; - case SettingsTabKey.plugin: - children.add(const _Plugin()); - break; case SettingsTabKey.account: children.add(const _Account()); break; @@ -485,7 +480,8 @@ class _GeneralState extends State<_General> { Widget other() { final incomingOnly = bind.isIncomingOnly(); final outgoingOnly = bind.isOutgoingOnly(); - final showAutoUpdate = isWindows && bind.mainIsInstalled(); + final showAutoUpdate = (isWindows && bind.mainIsInstalled()) || + (isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient()); final children = [ if (!isWeb && !incomingOnly) _OptionCheckBox(context, 'Confirm before closing multiple tabs', @@ -1297,6 +1293,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { reverse: true, enabled: enabled), ...directIp(context), whitelist(), + idWhitelist(), ...autoDisconnect(context), _OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label', kOptionKeepAwakeDuringIncomingSessions, @@ -1454,6 +1451,52 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { return tmpWrapper(); } + Widget idWhitelist() { + bool enabled = !locked; + RxBool hasIdWhitelist = idWhitelistNotEmpty().obs; + update() async { + hasIdWhitelist.value = idWhitelistNotEmpty(); + } + + onChanged(bool? checked) async { + changeIdWhiteList(callback: update); + } + + final isOptFixed = isOptionFixed(kOptionIdWhitelist); + return GestureDetector( + child: Tooltip( + message: translate('id_whitelist_tip'), + child: Obx(() => Row( + children: [ + Checkbox( + value: hasIdWhitelist.value, + onChanged: enabled && !isOptFixed ? onChanged : null) + .marginOnly(right: 5), + Offstage( + offstage: !hasIdWhitelist.value, + child: MouseRegion( + child: const Icon(Icons.warning_amber_rounded, + color: Color.fromARGB(255, 255, 204, 0)) + .marginOnly(right: 5), + cursor: SystemMouseCursors.click, + ), + ), + Expanded( + child: Text( + translate('Use ID whitelisting'), + style: TextStyle(color: disabledTextColor(context, enabled)), + )) + ], + )), + ), + onTap: enabled + ? () { + onChanged(!hasIdWhitelist.value); + } + : null, + ).marginOnly(left: _kCheckBoxLeftMargin); + } + Widget hide_cm(bool enabled) { return ChangeNotifierProvider.value( value: gFFI.serverModel, @@ -2207,51 +2250,6 @@ class _CheckboxState extends State<_Checkbox> { } } -class _Plugin extends StatefulWidget { - const _Plugin({Key? key}) : super(key: key); - - @override - State<_Plugin> createState() => _PluginState(); -} - -class _PluginState extends State<_Plugin> { - @override - Widget build(BuildContext context) { - bind.pluginListReload(); - final scrollController = ScrollController(); - return ChangeNotifierProvider.value( - value: pluginManager, - child: Consumer(builder: (context, model, child) { - return ListView( - controller: scrollController, - children: model.plugins.map((entry) => pluginCard(entry)).toList(), - ).marginOnly(bottom: _kListViewBottomMargin); - }), - ); - } - - Widget pluginCard(PluginInfo plugin) { - return ChangeNotifierProvider.value( - value: plugin, - child: Consumer( - builder: (context, model, child) => DesktopSettingsCard(plugin: model), - ), - ); - } - - Widget accountAction() { - return Obx(() => _Button( - gFFI.userModel.userName.value.isEmpty - ? 'Login' - : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})', - () => { - gFFI.userModel.userName.value.isEmpty - ? loginDialog() - : logOutConfirmDialog() - })); - } -} - class _Printer extends StatefulWidget { const _Printer({super.key}); @@ -2414,17 +2412,20 @@ class _AboutState extends State<_About> { final version = await bind.mainGetVersion(); final buildDate = await bind.mainGetBuildDate(); final fingerprint = await bind.mainGetFingerprint(); + final myId = await bind.mainGetMyId(); return { 'license': license, 'version': version, 'buildDate': buildDate, - 'fingerprint': fingerprint + 'fingerprint': fingerprint, + 'myId': myId }; }(), hasData: (data) { final license = data['license'].toString(); final version = data['version'].toString(); final buildDate = data['buildDate'].toString(); final fingerprint = data['fingerprint'].toString(); + final myId = data['myId'].toString(); const linkStyle = TextStyle(decoration: TextDecoration.underline); final scrollController = ScrollController(); return SingleChildScrollView( @@ -2446,6 +2447,9 @@ class _AboutState extends State<_About> { SelectionArea( child: Text('${translate('Fingerprint')}: $fingerprint') .marginSymmetric(vertical: 4.0)), + SelectionArea( + child: Text('${translate('ID')}: $myId') + .marginSymmetric(vertical: 4.0)), InkWell( onTap: () { launchUrlString('https://rustdesk.com/privacy.html'); @@ -2474,7 +2478,7 @@ class _AboutState extends State<_About> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license', + 'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license', style: const TextStyle(color: Colors.white), ), Text( diff --git a/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart new file mode 100644 index 00000000000..96493c62c49 --- /dev/null +++ b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart @@ -0,0 +1,24 @@ +class MacOSFullScreenFocusRecovery { + int _generation = 0; + int? _pendingGeneration; + + int? get pendingGeneration => _pendingGeneration; + + int queue() { + _generation += 1; + _pendingGeneration = _generation; + return _generation; + } + + void cancel() { + _pendingGeneration = null; + } + + bool isCurrent(int generation) => _pendingGeneration == generation; + + bool consume(int generation) { + if (!isCurrent(generation)) return false; + _pendingGeneration = null; + return true; + } +} diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index f4669644b85..79f382249d2 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -22,6 +22,7 @@ import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/tabbar_widget.dart'; +import 'macos_full_screen_focus_recovery.dart'; import 'package:flutter_hbb/native/custom_cursor.dart' if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; @@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget { FFI get ffi => (_lastState.value! as _RemotePageState)._ffi; + void releaseMacOSInputForTabTransfer() { + if (!isMacOS) return; + // Release before removing the source tab. Its delayed disposal must not + // disable a native keyboard hook already acquired by the destination page. + (_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput(); + } + @override State createState() { final state = _RemotePageState(id); @@ -76,10 +84,28 @@ class _RemotePageState extends State with AutomaticKeepAliveClientMixin, MultiWindowListener, + WidgetsBindingObserver, TickerProviderStateMixin { Timer? _timer; String keyboardMode = "legacy"; bool _isWindowBlur = false; + // Known macOS remote-input trade-offs (kept simple intentionally): + // 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog + // state. Reproduce: activate remote input, open a dialog, then type. + // 2. Delayed fullscreen recovery can race a local-control focus change; no + // owner state is added. Reproduce: focus the toolbar during a Space switch. + // 3. Input-source switching releases native input without updating this + // page's cache. Reproduce: switch sources, then type before and after + // clicking the remote image; the click reasserts input. + // These latches compensate for out-of-order macOS focus events. Treat them + // as coupled when changing a transition or _syncMacOSKeyboardGrab(). + AppLifecycleState? _macOSLifecycleState; + bool _macOSLocalFocusLost = false; + bool _macOSInputActive = false; + bool _macOSInputSuppressed = false; + final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery(); + bool _macOSExplicitFocusRequestPending = false; + StreamSubscription? _tabStateSubscription; final _cursorOverImage = false.obs; late RxBool _showRemoteCursor; late RxBool _zoomCursor; @@ -122,6 +148,13 @@ class _RemotePageState extends State void initState() { super.initState(); _ffi = FFI(widget.sessionId); + if (isMacOS) { + // SchedulerBinding.instance.lifecycleState is null in the first connection in a new window. + _macOSLifecycleState = SchedulerBinding.instance.lifecycleState; + WidgetsBinding.instance.addObserver(this); + _tabStateSubscription = + widget.tabController?.state.listen(_onMacOSTabStateChanged); + } Get.put(_ffi, tag: widget.id); _ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.canvasModel.activateLocalCursor(); @@ -149,7 +182,6 @@ class _RemotePageState extends State WakelockManager.enable(_uniqueKey); _ffi.ffiModel.updateEventListener(sessionId, widget.id); - if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.dialogManager.loadMobileActionsOverlayVisible(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -231,19 +263,224 @@ class _RemotePageState extends State _pointerLockCenterDebounceTimer = null; } + bool get _isSelectedTab { + final controller = widget.tabController; + if (controller == null) return true; + final tabState = controller.state.value; + final selected = tabState.selected; + return selected >= 0 && + selected < tabState.tabs.length && + tabState.tabs[selected].key == widget.id; + } + + bool get _isMacOSKeyboardContextActive { + return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + } + + void _onMacOSTabStateChanged(DesktopTabState _) { + if (!_isSelectedTab) { + _macOSFullScreenFocusRecovery.cancel(); + _syncMacOSKeyboardGrab(); + return; + } + // Tab listeners run synchronously. Defer the selected page so the previous + // page releases first; a late leave from it can disable the new session. + scheduleMicrotask(() { + if (mounted) { + _syncMacOSKeyboardGrab(reassert: true); + } + }); + } + + void _releaseMacOSRemoteInput() { + _macOSFullScreenFocusRecovery.cancel(); + _macOSExplicitFocusRequestPending = false; + _macOSInputSuppressed = true; + _macOSLocalFocusLost = true; + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + _rawKeyFocusNode.unfocus(); + } + + void _onMacOSFocusChange() { + // requestFocus() notifies later; only a recorded explicit request may clear + // the local-focus-loss latch. + if (_rawKeyFocusNode.hasPrimaryFocus) { + final explicitRequest = _macOSExplicitFocusRequestPending; + _macOSExplicitFocusRequestPending = false; + if (explicitRequest && _isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = false; + } + _syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest); + } else { + if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + if (_isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = true; + } + } + } + + // 1. Sync the keyboard grab state with the current context. + // 2. Call enterOrLeave() to update the input state in the FFI layer. + // 3. Request or unfocus the raw key focus node based on the current context. + // Flutter focus and native input are separate; native input activates only + // after the FocusNode has primary focus. + void _syncMacOSKeyboardGrab({ + bool reassert = false, + bool allowInactiveLifecycle = false, + }) { + if (!isMacOS) return; + // A secondary engine may stay hidden while its window is visible, so + // explicit pointer/fullscreen recovery must bypass the global lifecycle. + final lifecycleAllowsInput = allowInactiveLifecycle || + _macOSLifecycleState == null || + _macOSLifecycleState == AppLifecycleState.resumed; + // Input stays pointer-gated except for focused fullscreen recovery, which + // compensates when macOS omits PointerEnter during a Space switch. + final shouldFocus = lifecycleAllowsInput && + _isMacOSKeyboardContextActive && + !_macOSInputSuppressed && + _blockableOverlayState.middleBlocked.isFalse && + _cursorOverImage.value && + !_macOSLocalFocusLost; + final hasFocus = _rawKeyFocusNode.hasPrimaryFocus; + final shouldActivateInput = shouldFocus && hasFocus; + + if (shouldActivateInput != _macOSInputActive || + (shouldActivateInput && reassert)) { + _ffi.inputModel.enterOrLeave(shouldActivateInput); + } + _macOSInputActive = shouldActivateInput; + + if (!shouldFocus) { + _macOSExplicitFocusRequestPending = false; + if (hasFocus) _rawKeyFocusNode.unfocus(); + } else if (!hasFocus) { + _macOSExplicitFocusRequestPending = allowInactiveLifecycle; + _rawKeyFocusNode.requestFocus(); + } else { + _macOSExplicitFocusRequestPending = false; + } + } + + void _restoreMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen callbacks preserve recovery while hidden. Native window focus + // may bypass a stale hidden lifecycle for the newly visible Space. + if (!_macOSFullScreenFocusRecovery.isCurrent(generation) || + (!allowHiddenLifecycle && + _macOSLifecycleState == AppLifecycleState.hidden)) { + return; + } + final contextActive = + stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + // macOS can focus a fullscreen Space without sending PointerEnter. Native + // window focus is authoritative here; a later blur cancels this generation + // before an off-screen window can restore input. + final shouldInferPointerInside = !_cursorOverImage.value && + allowHiddenLifecycle && + stateGlobal.fullscreen.isTrue && + contextActive; + final canRestore = contextActive && + _blockableOverlayState.middleBlocked.isFalse && + (_cursorOverImage.value || shouldInferPointerInside); + if (!_macOSFullScreenFocusRecovery.consume(generation)) return; + if (!canRestore) { + // Consuming recovery here requires a later pointer/window/tab event. + return; + } + if (shouldInferPointerInside) { + _cursorOverImage.value = true; + } + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } + + void _scheduleMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen can deliver FocusNode loss after its callback; wait for frame + // completion and then advance one event-loop turn before restoring. + WidgetsBinding.instance.addPostFrameCallback((_) { + Timer.run(() { + if (mounted) { + _restoreMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + }); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _queueMacOSKeyboardAfterFullScreen({ + bool allowHiddenLifecycle = false, + }) { + final generation = _macOSFullScreenFocusRecovery.queue(); + if (_macOSLifecycleState == AppLifecycleState.paused || + _macOSLifecycleState == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + return; + } + _scheduleMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (!isMacOS || _macOSLifecycleState == state) return; + _macOSLifecycleState = state; + if (state == AppLifecycleState.resumed) { + _syncMacOSKeyboardGrab(reassert: true); + } else if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + + final generation = _macOSFullScreenFocusRecovery.pendingGeneration; + if (generation == null) return; + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.resumed) { + _scheduleMacOSKeyboardAfterFullScreen(generation: generation); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + } + } + @override void onWindowBlur() { super.onWindowBlur(); // On windows, we use `focus` way to handle keyboard better. // Now on Linux, there's some rdev issues which will break the input. - // We disable the `focus` way for non-Windows temporarily. - if (isWindows) { + // We disable the `focus` way for Linux temporarily. + if (isWindows || isMacOS) { _isWindowBlur = true; + } + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + // A blur or Space switch may not emit PointerExit, so cursor state alone + // cannot prevent the old remote surface from reclaiming the keyboard. + _macOSLocalFocusLost = true; + } + if (isWindows) { // unfocus the primary-focus when the whole window is lost focus, // and let OS to handle events instead. _rawKeyFocusNode.unfocus(); } stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); // When window loses focus, temporarily release relative mouse mode constraints // to allow user to interact with other applications normally. @@ -257,16 +494,41 @@ class _RemotePageState extends State void onWindowFocus() { super.onWindowFocus(); // See [onWindowBlur]. - if (isWindows) { + if (isWindows || isMacOS) { _isWindowBlur = false; } + if (isMacOS) stateGlobal.getInputSource(force: true); stateGlobal.isFocused.value = true; + // Normal macOS windows wait for PointerEnter or PointerDown. A focused + // fullscreen Space queues delayed recovery; if this window blurs again, the + // pending recovery is cancelled before native input can reactivate. + // Regression: switch directly between fullscreen remote Spaces without + // moving or clicking; only the newly focused session may receive input. + if (isMacOS && + stateGlobal.fullscreen.isTrue && + !_ffi.inputModel.relativeMouseMode.value) { + // Native window focus is authoritative when a secondary engine retains a + // stale hidden lifecycle state after its fullscreen Space becomes visible. + _queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true); + } + // Restore relative mouse mode constraints when window regains focus. if (_ffi.inputModel.relativeMouseMode.value) { - _rawKeyFocusNode.requestFocus(); + if (isMacOS) { + // Native relative mode retains pointer capture and does not emit + // PointerEnter after window focus returns. Restore both latches unless + // a local overlay still owns input. + if (_blockableOverlayState.middleBlocked.isFalse) { + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + } + } else { + _rawKeyFocusNode.requestFocus(); + } _ffi.inputModel.onWindowFocus(); } + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); } @override @@ -327,6 +589,13 @@ class _RemotePageState extends State void onWindowMinimize() { super.onWindowMinimize(); WakelockManager.disable(_uniqueKey); + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + _isWindowBlur = true; + _cursorOverImage.value = false; + stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); + } // Release cursor constraints when minimized if (_ffi.inputModel.relativeMouseMode.value) { _ffi.inputModel.onWindowBlur(); @@ -338,6 +607,7 @@ class _RemotePageState extends State super.onWindowEnterFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(true); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -346,6 +616,7 @@ class _RemotePageState extends State super.onWindowLeaveFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(false); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -354,6 +625,14 @@ class _RemotePageState extends State final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; // https://github.com/flutter/flutter/issues/64935 + if (isMacOS) { + // Tab moves release before transfer to avoid a late retained-session leave. + if (closeSession) { + _releaseMacOSRemoteInput(); + } + _tabStateSubscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); + } super.dispose(); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); @@ -368,8 +647,9 @@ class _RemotePageState extends State _ffi.inputModel.onRelativeMouseModeDisabled = null; // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). _ffi.textureModel.onRemotePageDispose(closeSession); - if (closeSession) { + if (closeSession && !isMacOS) { // ensure we leave this session, this is a double check + // enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS. _ffi.inputModel.enterOrLeave(false); } DesktopMultiWindow.removeListener(this); @@ -444,6 +724,8 @@ class _RemotePageState extends State } else { _ffi.inputModel.enterOrLeave(false); } + } else if (isMacOS) { + _onMacOSFocusChange(); } }, inputModel: _ffi.inputModel, @@ -549,7 +831,11 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } else if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } @@ -575,7 +861,9 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _syncMacOSKeyboardGrab(); + } else if (!isWindows) { _ffi.inputModel.enterOrLeave(false); } } @@ -600,17 +888,29 @@ class _RemotePageState extends State onEnter: onEnter, onExit: onExit, onPointerDown: (event) { - // A double check for blur status. + // A double check for blur status on Windows and macOS. // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. // Sometimes the system does not send the necessary focus event to flutter. We should manually // handle this inconsistent status by setting `_isWindowBlur` to false. So we can // ensure the grab-key thread is running when our users are clicking the remote canvas. - if (_isWindowBlur) { + if ((isWindows || isMacOS) && _isWindowBlur) { debugPrint( "Unexpected status: onPointerDown is triggered while the remote window is in blur status"); _isWindowBlur = false; } - if (!_rawKeyFocusNode.hasFocus) { + if (isMacOS) { + // Regions without matching enter/exit callbacks cannot safely own + // keyboard state. + if (onEnter == null || onExit == null) return; + if (!stateGlobal.isFocused.value) { + stateGlobal.isFocused.value = true; + } + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab( + reassert: !isInputSourceFlutter, allowInactiveLifecycle: true); + } else if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } }, diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index ccd5935ceda..0b94a491613 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State { final args = jsonDecode(call.arguments); final id = args['id']; final close = args['close']; + RemotePage? remotePage; try { - final remotePage = tabController.state.value.tabs + remotePage = tabController.state.value.tabs .firstWhere((tab) => tab.key == id) .page as RemotePage; returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString(); } catch (e) { debugPrint('Failed to get cached session data: $e'); } - if (close && returnValue != null) { + if (close && returnValue != null && remotePage != null) { + remotePage.releaseMacOSInputForTabTransfer(); closeSessionOnDispose[id] = false; tabController.closeBy(id); } diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 8bd7df08be4..a814b9f7e79 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -495,14 +495,14 @@ class _CmHeaderState extends State<_CmHeader> if (client.type_() == ClientType.file) FittedBox( child: Text( - translate("File Transfer"), + translate("Transfer file"), style: TextStyle(color: Colors.white70, fontSize: 12), ), ), if (client.type_() == ClientType.camera) FittedBox( child: Text( - translate("View Camera"), + translate("View camera"), style: TextStyle(color: Colors.white70, fontSize: 12), ), ), diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index d38dc4a8b16..e5e1dbb8dc7 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -95,6 +95,13 @@ class _TerminalPageState extends State // Register this terminal model with FFI for event routing _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + // Auto-close tab when shell exits + _terminalModel.onClosed = () { + if (mounted) { + widget.tabController.closeBy(widget.tabKey); + } + }; + // Initialize terminal connection WidgetsBinding.instance.addPostFrameCallback((_) { widget.tabController.onSelected?.call(widget.id); diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart index c45ec4d8686..6eb65b11d19 100644 --- a/flutter/lib/desktop/pages/view_camera_page.dart +++ b/flutter/lib/desktop/pages/view_camera_page.dart @@ -127,7 +127,6 @@ class _ViewCameraPageState extends State WakelockManager.enable(_uniqueKey); _ffi.ffiModel.updateEventListener(sessionId, widget.id); - if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); _ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId); _ffi.dialogManager.loadMobileActionsOverlayVisible(); DesktopMultiWindow.addListener(this); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f88f..2627627a63e 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -10,8 +10,6 @@ import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; -import 'package:flutter_hbb/plugin/common.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; @@ -1478,20 +1476,11 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - final Widget pluginItem; - _DisplayMenu( - {Key? key, - required this.id, + const _DisplayMenu( + {required this.id, required this.ffi, required this.state, - required this.setFullscreen}) - : pluginItem = LocationItem.createLocationItem( - id, - ffi, - kLocationClientRemoteToolbarDisplay, - true, - ), - super(key: key); + required this.setFullscreen}); @override State<_DisplayMenu> createState() => _DisplayMenuState(); @@ -1582,9 +1571,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { ]); } } - if (ffi.connType == ConnType.defaultConn) { - menuChildren.add(widget.pluginItem); - } return menuChildren; } @@ -2484,6 +2470,8 @@ class _KeyboardMenu extends StatelessWidget { ? (v) async { if (v != null) { await stateGlobal.setInputSource(ffi.sessionId, v); + // Release native input; see the macOS trade-offs in RemotePage. + if (isMacOS) ffi.inputModel.enterOrLeave(false); await ffi.ffiModel.checkDesktopKeyboardMode(); await ffi.inputModel.updateKeyboardMode(); } @@ -2740,7 +2728,9 @@ class _RecordMenu extends StatelessWidget { Widget build(BuildContext context) { var ffi = Provider.of(context); var recordingModel = Provider.of(context); - final visible = + final hideRecordingButton = + bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y'; + final visible = !hideRecordingButton && (recordingModel.start || ffi.permissions['recording'] != false); if (!visible) return Offstage(); return _IconMenuButton( diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 9bd68ed60a6..5f234cb6952 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart'; import 'mobile/widgets/deploy_dialog.dart'; import 'models/platform_model.dart'; -import 'package:flutter_hbb/plugin/handlers.dart' - if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart'; - /// Basic window and launch properties. int? kWindowId; WindowType? kWindowType; @@ -141,8 +138,6 @@ void runMainApp(bool startService) async { await bind.mainCheckConnectStatus(); if (startService) { gFFI.serverModel.startService(); - bind.pluginSyncUi(syncTo: kAppTypeMain); - bind.pluginListReload(); } await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]); gFFI.userModel.refreshCurrentUser(); @@ -570,12 +565,6 @@ _registerEventHandler() { reloadAllWindows(); }); } - // Register native handlers. - if (isDesktop) { - platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async { - NativeUiHandler.instance.onEvent(evt); - }); - } if (isAndroid) { platformFFI.registerEventHandler( 'android_needs_deploy', 'android_needs_deploy', (_) async { @@ -588,7 +577,8 @@ _registerEventHandler() { Widget keyListenerBuilder(BuildContext context, Widget? child) { return RawKeyboardListener( - focusNode: FocusNode(), + // `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4" + focusNode: FocusNode(skipTraversal: isWeb), child: child ?? Container(), onKey: (RawKeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.shiftLeft) { diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index cb40aff81a5..cbc99fd2bfa 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -78,6 +78,7 @@ class _SettingsState extends State with WidgetsBindingObserver { var _enableAbr = false; var _denyLANDiscovery = false; var _onlyWhiteList = false; + var _onlyIdWhiteList = false; var _enableDirectIPAccess = false; var _enableRecordSession = false; var _enableHardwareCodec = false; @@ -89,6 +90,7 @@ class _SettingsState extends State with WidgetsBindingObserver { var _directAccessPort = ""; var _fingerprint = ""; var _buildDate = ""; + var _myId = ""; var _autoDisconnectTimeout = ""; var _hideServer = false; var _hideProxy = false; @@ -109,6 +111,7 @@ class _SettingsState extends State with WidgetsBindingObserver { _denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery, bind.mainGetOptionSync(key: kOptionEnableLanDiscovery)); _onlyWhiteList = whitelistNotEmpty(); + _onlyIdWhiteList = idWhitelistNotEmpty(); _enableDirectIPAccess = option2bool( kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer)); _enableRecordSession = option2bool(kOptionEnableRecordSession, @@ -217,6 +220,12 @@ class _SettingsState extends State with WidgetsBindingObserver { _buildDate = buildDate; } + final myId = await bind.mainGetMyId(); + if (_myId != myId) { + update = true; + _myId = myId; + } + final isUsingPublicServer = await bind.mainIsUsingPublicServer(); if (_isUsingPublicServer != isUsingPublicServer) { update = true; @@ -400,6 +409,29 @@ class _SettingsState extends State with WidgetsBindingObserver { changeWhiteList(callback: update); }, ), + SettingsTile.switchTile( + title: Row(children: [ + Expanded(child: Text(translate('Use ID whitelisting'))), + Offstage( + offstage: !_onlyIdWhiteList, + child: const Icon(Icons.warning_amber_rounded, + color: Color.fromARGB(255, 255, 204, 0))) + .marginOnly(left: 5) + ]), + initialValue: _onlyIdWhiteList, + onToggle: (_) async { + update() async { + final onlyIdWhiteList = idWhitelistNotEmpty(); + if (onlyIdWhiteList != _onlyIdWhiteList) { + setState(() { + _onlyIdWhiteList = onlyIdWhiteList; + }); + } + } + + changeIdWhiteList(callback: update); + }, + ), SettingsTile.switchTile( title: Text(translate('Adaptive bitrate')), initialValue: _enableAbr, @@ -982,6 +1014,14 @@ class _SettingsState extends State with WidgetsBindingObserver { child: Text(_fingerprint), ), leading: Icon(Icons.fingerprint)), + SettingsTile( + onPressed: (context) => onCopyId(_myId), + title: Text(translate("ID")), + value: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text(_myId), + ), + leading: Icon(Icons.perm_identity)), SettingsTile( title: Text(translate("Privacy Statement")), onPressed: (context) => diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index aff85b40c84..800b0f8f475 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -5,8 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; +import 'package:flutter_hbb/web/dummy.dart' + if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; import '../../desktop/pages/terminal_connection_manager.dart'; @@ -42,6 +47,11 @@ class _TerminalPageState extends State final GlobalKey _keyboardKey = GlobalKey(); double _keyboardHeight = 0; late bool _showTerminalExtraKeys; + // Ctrl lock state for virtual keyboard: active key presses are mapped to control codes + bool _ctrlLocked = false; + bool _altLocked = false; + // Row3 expand/collapse state for compact keyboard layout + bool _row3Expanded = false; // For iOS edge swipe gesture double _swipeStartX = 0; double _swipeCurrentX = 0; @@ -59,6 +69,10 @@ class _TerminalPageState extends State super.initState(); WidgetsBinding.instance.addObserver(this); + if (isWeb) { + loadLocalTerminalFontIfNeeded(); + } + debugPrint( '[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}'); @@ -83,10 +97,29 @@ class _TerminalPageState extends State // Register this terminal model with FFI for event routing _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + // Auto-close connection when shell exits + _terminalModel.onClosed = () { + if (mounted) { + closeConnection(id: widget.id); + } + }; + // Web desktop users have full hardware keyboard access, so the on-screen // terminal extra keys bar is unnecessary and disabled. _showTerminalExtraKeys = !isWebDesktop && mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + _terminalModel.isCtrlLocked = () => _ctrlLocked; + _terminalModel.clearCtrlLock = () { + if (_ctrlLocked) setState(() => _ctrlLocked = false); + }; + _terminalModel.isAltLocked = () => _altLocked; + _terminalModel.clearAltLock = () { + if (_altLocked) setState(() => _altLocked = false); + }; + // Load Row3 expand/collapse state from persistent storage. The raw option + // read keeps Row3 collapsed when no value has been saved yet. + _row3Expanded = + bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y'; // Initialize terminal connection WidgetsBinding.instance.addPostFrameCallback((_) { _ffi.dialogManager @@ -141,6 +174,39 @@ class _TerminalPageState extends State return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); } + /// Pastes clipboard text through TerminalModel so keyboard-only modifiers and + /// mobile Enter normalization never alter clipboard data. + Future _pasteClipboardText() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text == null || !mounted) return; + + await _terminalModel.pasteText(text); + if (mounted) { + _terminalModel.terminalController.clearSelection(); + } + } + + KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { + final hardwareKeyboard = HardwareKeyboard.instance; + final shouldPaste = shouldHandleTerminalPasteShortcut( + logicalKey: event.logicalKey, + isKeyDown: event is KeyDownEvent, + isKeyRepeat: event is KeyRepeatEvent, + controlPressed: hardwareKeyboard.isControlPressed, + metaPressed: hardwareKeyboard.isMetaPressed, + altPressed: hardwareKeyboard.isAltPressed, + shiftPressed: hardwareKeyboard.isShiftPressed, + modifierLockActive: _ctrlLocked || _altLocked, + ); + if (!shouldPaste) return KeyEventResult.ignored; + + // Only locked virtual modifiers need interception. Without a lock, keep + // xterm's default hardware paste behavior, including bracketed paste mode. + unawaited(_pasteClipboardText()); + return KeyEventResult.handled; + } + @override Widget build(BuildContext context) { super.build(context); @@ -178,6 +244,7 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, + onKeyEvent: _handleTerminalKeyEvent, padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; @@ -186,11 +253,7 @@ class _TerminalPageState extends State _terminalModel.terminalController.clearSelection(); await Clipboard.setData(ClipboardData(text: text)); } else { - final data = await Clipboard.getData('text/plain'); - final text = data?.text; - if (text != null) { - _terminalModel.terminal.paste(text); - } + await _pasteClipboardText(); } }, ); @@ -317,66 +380,171 @@ class _TerminalPageState extends State mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ + // Row 1 follows the latest reviewed PR layout. Row( mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildKeyButton('Esc'), - const SizedBox(width: 2), - _buildKeyButton('/'), - const SizedBox(width: 2), - _buildKeyButton('|'), - const SizedBox(width: 2), - _buildKeyButton('Home'), - const SizedBox(width: 2), - _buildKeyButton('↑'), - const SizedBox(width: 2), - _buildKeyButton('End'), - const SizedBox(width: 2), - _buildKeyButton('PgUp'), - ], + children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys), ), + // Row 2 ends with the full-width Row3 collapse/expand toggle. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildKeyButton('Tab'), - const SizedBox(width: 2), - _buildKeyButton('Ctrl+C'), - const SizedBox(width: 2), - _buildKeyButton('~'), - const SizedBox(width: 2), - _buildKeyButton('←'), - const SizedBox(width: 2), - _buildKeyButton('↓'), - const SizedBox(width: 2), - _buildKeyButton('→'), - const SizedBox(width: 2), - _buildKeyButton('PgDn'), + ..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys), + const SizedBox(width: terminalKeyboardKeySpacing), + _buildCollapseButton(), ], ), + // Row 3 restores paging keys and trailing alignment placeholders. + if (_row3Expanded) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys), + for (var i = 0; + i < terminalKeyboardRow3TrailingPlaceholderCount; + i++) ...[ + const SizedBox(width: terminalKeyboardKeySpacing), + const SizedBox(width: terminalKeyboardKeyWidth), + ], + ], + ), ], ), ), ); } + // Ctrl toggle button with highlighted locked state + Widget _buildCtrlKeyButton() { + return _buildModifierToggleButton( + text: 'Ctrl', + semanticsLabel: 'Ctrl', + isLocked: _ctrlLocked, + onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked), + ); + } + + // Alt toggle button with highlighted locked state + Widget _buildAltKeyButton() { + return _buildModifierToggleButton( + text: 'Alt', + semanticsLabel: 'Alt', + isLocked: _altLocked, + onPressed: () => setState(() => _altLocked = !_altLocked), + ); + } + + // Collapse/expand toggle button for Row3 + void _toggleRow3Expanded() { + final willExpand = !_row3Expanded; + final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: _row3Expanded, + willExpand: willExpand, + ctrlLocked: _ctrlLocked, + altLocked: _altLocked, + ); + setState(() { + _row3Expanded = willExpand; + if (shouldClearModifiers) { + _ctrlLocked = false; + _altLocked = false; + } + }); + mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand); + + // The floating keyboard height changes after Row3 is inserted/removed. + // Re-measure on the next frame so terminal padding uses the new height. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_showTerminalExtraKeys) return; + setState(() { + _updateKeyboardHeight(); + }); + }); + } + + Widget _buildCollapseButton() { + return Semantics( + label: translate('Show terminal extra keys'), + toggled: _row3Expanded, + child: ElevatedButton( + onPressed: _toggleRow3Expanded, + child: Text(_row3Expanded ? '∧' : '∨'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + /// Builds a fixed-width key sequence with the reviewed 2dp spacing. + List _buildKeyboardKeyButtons(List labels) { + return [ + for (var i = 0; i < labels.length; i++) ...[ + _buildKeyButton(labels[i]), + if (i < labels.length - 1) + const SizedBox(width: terminalKeyboardKeySpacing), + ], + ]; + } + + /// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior. + /// When [isLocked] is true, the button highlights in blue and the next + /// single-character input is mapped to its modified equivalent. + Widget _buildModifierToggleButton({ + required String text, + required String semanticsLabel, + required bool isLocked, + required VoidCallback onPressed, + }) { + return Semantics( + // Ctrl and Alt are technical key names and intentionally stay unchanged. + label: semanticsLabel, + toggled: isLocked, + child: ElevatedButton( + onPressed: onPressed, + child: Text(text), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: isLocked + ? Colors.blue + : Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: isLocked + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + Widget _buildKeyButton(String label) { + if (label == 'Ctrl') return _buildCtrlKeyButton(); + if (label == 'Alt') return _buildAltKeyButton(); + return ElevatedButton( onPressed: () { _sendKeyToTerminal(label); }, child: Text(label), style: ElevatedButton.styleFrom( - minimumSize: const Size(48, 32), + minimumSize: const Size(terminalKeyboardKeyWidth, 32), padding: EdgeInsets.zero, textStyle: const TextStyle(fontSize: 12), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } void _sendKeyToTerminal(String key) { - String? send; + String send; switch (key) { case 'Esc': @@ -420,9 +588,7 @@ class _TerminalPageState extends State break; } - if (send != null) { - _terminalModel.sendVirtualKey(send); - } + _terminalModel.sendVirtualKey(send); } // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 diff --git a/flutter/lib/mobile/terminal_keyboard_utils.dart b/flutter/lib/mobile/terminal_keyboard_utils.dart new file mode 100644 index 00000000000..9248d1d4ae9 --- /dev/null +++ b/flutter/lib/mobile/terminal_keyboard_utils.dart @@ -0,0 +1,20 @@ +/// Reviewed mobile terminal keyboard layout from PR #15532. +/// +/// Keeping the key order outside the widget makes the intended layout explicit +/// and prevents behavior fixes from silently moving keys between rows. +const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\']; +const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→']; +const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn']; + +const terminalKeyboardKeyWidth = 48.0; +const terminalKeyboardKeySpacing = 2.0; + +/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it. +const terminalKeyboardRow3TrailingPlaceholderCount = 2; + +/// Returns the fixed width occupied by a row of equally sized key slots. +double terminalKeyboardRowWidth(int slotCount) { + if (slotCount <= 0) return 0; + return slotCount * terminalKeyboardKeyWidth + + (slotCount - 1) * terminalKeyboardKeySpacing; +} diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index e65c327906f..9b8aae8812e 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -1,4 +1,12 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Identifies where terminal input originated so paste data can bypass all +/// keyboard-only transformations. +enum TerminalInputSource { + keyboard, + paste, +} /// Returns true when a stale mobile one-shot Shift state should be released /// by replaying a tracked Shift key-down as a synthesized key-up. @@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({ } return true; } + +/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload. +/// +String applyTerminalInputModifiers( + String data, { + required bool ctrlLocked, + required bool altLocked, +}) { + var result = data; + if (ctrlLocked) { + result = _applyTerminalCtrlModifier(result); + } + if (altLocked) { + result = '\x1B$result'; + } + return result; +} + +/// Builds the exact payload xterm sends for paste, without applying modifiers. +String terminalPastePayload(String text, {required bool bracketedPasteMode}) { + if (!bracketedPasteMode) { + return text; + } + return '\x1B[200~$text\x1B[201~'; +} + +/// Returns whether one-shot Ctrl/Alt may transform and consume this input. +/// +/// xterm emits terminal control keys as either one control byte or a longer +/// escape sequence. Neither form is ordinary text input, so a pending modifier +/// must survive until the user enters a printable character. +bool shouldApplyTerminalInputModifiers(String data) { + if (data.characters.length != 1) return false; + final codeUnit = data.codeUnitAt(0); + return codeUnit >= 0x20 && codeUnit != 0x7F; +} + +/// Builds the payload sent to the remote terminal for keyboard and paste input. +/// +/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt +/// mapping. Paste input deliberately bypasses both transformations so even a +/// one-character clipboard payload is preserved exactly. +String prepareTerminalInputPayload( + String data, { + required TerminalInputSource source, + required bool isMobileOrWebMobile, + required bool bracketedPasteMode, + required bool ctrlLocked, + required bool altLocked, +}) { + if (source == TerminalInputSource.paste) { + return terminalPastePayload( + data, + bracketedPasteMode: bracketedPasteMode, + ); + } + + var result = data; + if (isMobileOrWebMobile && result == '\n') { + result = '\r'; + } + if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) { + result = applyTerminalInputModifiers( + result, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + } + return result; +} + +/// Returns true when a hardware paste shortcut must bypass keyboard modifiers. +/// +/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only +/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a +/// one-character paste as normal text when bracketed paste mode is disabled. +bool shouldHandleTerminalPasteShortcut({ + required LogicalKeyboardKey logicalKey, + required bool isKeyDown, + required bool isKeyRepeat, + required bool controlPressed, + required bool metaPressed, + required bool altPressed, + required bool shiftPressed, + required bool modifierLockActive, +}) { + if (!modifierLockActive) return false; + if (!isKeyDown && !isKeyRepeat) return false; + if (logicalKey != LogicalKeyboardKey.keyV) return false; + if (altPressed || shiftPressed) return false; + return controlPressed != metaPressed; +} + +/// Returns true when collapsing Row3 should also clear hidden modifier state. +bool shouldClearTerminalModifiersWhenRow3Collapses({ + required bool wasExpanded, + required bool willExpand, + required bool ctrlLocked, + required bool altLocked, +}) { + return wasExpanded && !willExpand && (ctrlLocked || altLocked); +} + +String _applyTerminalCtrlModifier(String data) { + // Ctrl mappings are defined only for ASCII scalars. A visible character can + // be multiple scalars (for example, a decomposed accent), so leave those + // graphemes untouched instead of rewriting only their ASCII base letter. + final graphemes = data.characters.toList(growable: false); + if (graphemes.length != 1) { + return data; + } + + final runes = graphemes.single.runes.toList(growable: false); + if (runes.length != 1) { + return data; + } + + final code = runes.single; + if (code >= 0x61 && code <= 0x7A) { + return String.fromCharCode(code - 0x60); + } + if (code >= 0x41 && code <= 0x5A) { + return String.fromCharCode(code - 0x40); + } + if (code == 0x20) { + return String.fromCharCode(0); + } + if (code == 0x5B) { + return String.fromCharCode(27); + } + if (code == 0x5C) { + return String.fromCharCode(28); + } + if (code == 0x5D) { + return String.fromCharCode(29); + } + if (code == 0x5E) { + return String.fromCharCode(30); + } + if (code == 0x5F || code == 0x2F) { + return String.fromCharCode(31); + } + return data; +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 3054ffa96d1..68ec58cc32a 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/models/desktop_render_texture.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; -import 'package:flutter_hbb/plugin/event.dart'; -import 'package:flutter_hbb/plugin/manager.dart'; -import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/http_service.dart' as http; @@ -112,6 +109,9 @@ class CachedPeerData { class FfiModel with ChangeNotifier { CachedPeerData cachedPeerData = CachedPeerData(); PeerInfo _pi = PeerInfo(); + int? lastUserDisplay; + int? pendingMonitorRestore; + Timer? _pendingRestoreTimer; Rect? _rect; var _inputBlocked = false; @@ -248,6 +248,8 @@ class FfiModel with ChangeNotifier { clear() { _pi = PeerInfo(); + lastUserDisplay = null; + _cancelPendingMonitorRestore(); _secure = null; _direct = null; _inputBlocked = false; @@ -432,15 +434,6 @@ class FfiModel with ChangeNotifier { parent.target?.serverModel.updateVoiceCallState(evt); } else if (name == 'fingerprint') { FingerprintState.find(peerId).value = evt['fingerprint'] ?? ''; - } else if (name == 'plugin_manager') { - pluginManager.handleEvent(evt); - } else if (name == 'plugin_event') { - handlePluginEvent(evt, - (Map e) => handleMsgBox(e, sessionId, peerId)); - } else if (name == 'plugin_reload') { - handleReloading(evt); - } else if (name == 'plugin_option') { - handleOption(evt); } else if (name == "sync_peer_hash_password_to_personal_ab") { if (desktopType == DesktopType.main || isWeb || isMobile) { final id = evt['id']; @@ -932,6 +925,7 @@ class FfiModel with ChangeNotifier { // frame briefly, then shows the Connecting overlay. if (_restartReconnectDelayTimer == null) { parent.target?.inputModel.setRelativeMouseMode(false); + _cancelPendingMonitorRestore(); bind.sessionReconnect(sessionId: sessionId, forceRelay: false); clearPermissions(); // Retry once more after the silent window so restart reconnect attempts @@ -1084,10 +1078,22 @@ class FfiModel with ChangeNotifier { } } + void _cancelPendingMonitorRestore() { + _pendingRestoreTimer?.cancel(); + _pendingRestoreTimer = null; + pendingMonitorRestore = null; + } + + void cancelPendingRestoreTimer() { + _pendingRestoreTimer?.cancel(); + _pendingRestoreTimer = null; + } + void reconnect(OverlayDialogManager dialogManager, SessionID sessionId, bool forceRelay) { // Disable relative mouse mode before reconnecting to ensure cursor is released. parent.target?.inputModel.setRelativeMouseMode(false); + _cancelPendingMonitorRestore(); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); clearPermissions(); dialogManager.dismissAll(); @@ -1401,6 +1407,25 @@ class FfiModel with ChangeNotifier { // now replaced to _updateCurDisplay updateCurDisplay(sessionId); } + // After reconnecting, restore the last selected monitor once the canvas is ready. + // Switching earlier can offset the view if the monitor sizes differ. + final last = lastUserDisplay; + pendingMonitorRestore = (!isCache && + last != null && + last != currentDisplay && + bind.sessionGetUseAllMyDisplaysForTheRemoteSession( + sessionId: sessionId) != + 'Y' && + ((last == kAllDisplayValue && _pi.displays.isNotEmpty) || + (last >= 0 && last < _pi.displays.length))) + ? last + : null; + // Fallback if the first image event never reaches this tab (multi-UI). + _pendingRestoreTimer?.cancel(); + if (pendingMonitorRestore != null) { + _pendingRestoreTimer = Timer(const Duration(milliseconds: 1500), + () => parent.target?._applyPendingMonitorRestore()); + } if (displays.isNotEmpty) { _reconnects = 1; _offlineReconnectStartTime = null; @@ -1915,6 +1940,12 @@ class ImageModel with ChangeNotifier { platformFFI.nextRgba(sessionId, display); } + // web only: image already created from a decoded WebCodecs frame + Future onImage( + int display, ui.Image image, bool Function() isCurrentSession) async { + await update(image, isCurrentSession: isCurrentSession); + } + decodeAndUpdate(int display, Uint8List rgba) async { final pid = parent.target?.id; final rect = parent.target?.ffiModel.pi.getDisplayRect(display); @@ -1926,11 +1957,16 @@ class ImageModel with ChangeNotifier { ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888, ); - if (parent.target?.id != pid) return; + if (parent.target?.id != pid) { + image?.dispose(); + return; + } await update(image); } - update(ui.Image? image) async { + Future update(ui.Image? image, + {bool Function()? isCurrentSession}) async { + if (_disposeIfStale(image, isCurrentSession)) return; if (_image == null && image != null) { if (isDesktop || isWebDesktop) { await parent.target?.canvasModel.updateViewStyle(); @@ -1941,11 +1977,19 @@ class ImageModel with ChangeNotifier { await initializeCursorAndCanvas(parent.target!); } } + if (_disposeIfStale(image, isCurrentSession)) return; _image?.dispose(); _image = image; if (image != null) notifyListeners(); } + bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) { + if (image == null || isCurrentSession == null) return false; + if (isCurrentSession()) return false; + image.dispose(); + return true; + } + // mobile only double get maxScale { if (_image == null) return 1.5; @@ -3816,6 +3860,15 @@ class FFI { onEvent2UIRgba(); imageModel.onRgba(display, data); }); + platformFFI.setVideoFrameCallback((int display, ui.Image image, + bool Function() isCurrentSession) async { + if (!isCurrentSession()) { + image.dispose(); + return; + } + await onEvent2UIRgba(); + await imageModel.onImage(display, image, isCurrentSession); + }); this.id = id; return; } @@ -3903,7 +3956,7 @@ class FFI { this.id = id; } - void onEvent2UIRgba() async { + Future onEvent2UIRgba() async { if (ffiModel.waitForImageDialogShow.isTrue) { ffiModel.waitForImageDialogShow.value = false; ffiModel.waitForImageTimer?.cancel(); @@ -3911,17 +3964,35 @@ class FFI { } if (ffiModel.waitForFirstImage.value == true) { ffiModel.waitForFirstImage.value = false; + ffiModel.cancelPendingRestoreTimer(); ffiModel.resetRestartReconnectState(); dialogManager.dismissAll(); - await canvasModel.updateViewStyle(); - await canvasModel.updateScrollStyle(); - await canvasModel.initializeEdgeScrollEdgeThickness(); - for (final cb in imageModel.callbacksOnFirstImage) { - cb(id); + try { + await canvasModel.updateViewStyle(); + await canvasModel.updateScrollStyle(); + await canvasModel.initializeEdgeScrollEdgeThickness(); + for (final cb in imageModel.callbacksOnFirstImage) { + cb(id); + } + } finally { + _applyPendingMonitorRestore(); } } } + void _applyPendingMonitorRestore() { + final restore = ffiModel.pendingMonitorRestore; + ffiModel._cancelPendingMonitorRestore(); + if (restore == null || closed) return; + // The display list may have changed since the restore was queued. + final displays = ffiModel.pi.displays; + if ((restore == kAllDisplayValue && displays.isNotEmpty) || + (restore >= 0 && restore < displays.length)) { + openMonitorInTheSameTab(restore, this, ffiModel.pi, + recordSelection: false, updateCursorPos: false); + } + } + /// Login with [password], choose if the client should [remember] it. void login(String osUsername, String osPassword, SessionID sessionId, String password, bool remember) { @@ -3941,6 +4012,9 @@ class FFI { /// Close the remote session. Future close({bool closeSession = true}) async { closed = true; + if (isWeb) { + platformFFI.clearVideoFrameCallback(); + } chatModel.close(); // Close all terminal models for (final model in _terminalModels.values) { diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index b57867838c4..8c3c5cf7104 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; +import 'dart:ui' as ui; import 'package:device_info_plus/device_info_plus.dart'; import 'package:external_path/external_path.dart'; @@ -25,6 +26,23 @@ typedef F3 = Pointer Function(Pointer, int); typedef F3Dart = Pointer Function(Pointer, Int32); typedef HandleEvent = Future Function(Map evt); +/// The Linux bundle keeps the core library at lib/librustdesk.so next to the +/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain +/// name relies on the loader search path, which repackaged installs may not +/// cover. https://github.com/rustdesk/rustdesk/discussions/14407 +DynamicLibrary _openLinuxCoreLib() { + final bundled = + '${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so'; + try { + if (File(bundled).existsSync()) { + return DynamicLibrary.open(bundled); + } + } catch (e) { + debugPrint("Failed to load '$bundled': $e"); + } + return DynamicLibrary.open('librustdesk.so'); +} + /// FFI wrapper around the native Rust core. /// Hides the platform differences. class PlatformFFI { @@ -120,7 +138,7 @@ class PlatformFFI { final dylib = isAndroid ? DynamicLibrary.open('librustdesk.so') : isLinux - ? DynamicLibrary.open('librustdesk.so') + ? _openLinuxCoreLib() : isWindows ? DynamicLibrary.open('librustdesk.dll') : @@ -266,6 +284,12 @@ class PlatformFFI { void setRgbaCallback(void Function(int, Uint8List) fun) async {} + // web only, decoded WebCodecs frames arriving as ready-made images + void setVideoFrameCallback( + Future Function(int, ui.Image, bool Function()) fun) {} + + void clearVideoFrameCallback() {} + void startDesktopWebListener() {} void stopDesktopWebListener() {} diff --git a/flutter/lib/models/rustdesk_terminal.dart b/flutter/lib/models/rustdesk_terminal.dart new file mode 100644 index 00000000000..6e3f35dfdf4 --- /dev/null +++ b/flutter/lib/models/rustdesk_terminal.dart @@ -0,0 +1,14 @@ +import 'package:xterm/xterm.dart'; + +class RustDeskTerminal extends Terminal { + RustDeskTerminal({super.maxLines}); + + @override + void eraseScrollbackOnly() { + final scrollBack = buffer.scrollBack; + if (scrollBack == 0) return; + + // Selection anchors require retained buffer lines to be reindexed. + buffer.lines.remove(0, scrollBack); + } +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 8961d2dd8bf..63e83120236 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'dart:convert'; import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; import 'package:xterm/xterm.dart'; +import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; +import 'rustdesk_terminal.dart'; +import 'terminal_mouse_handler.dart'; class TerminalModel with ChangeNotifier { final String id; // peer id @@ -23,7 +25,25 @@ class TerminalModel with ChangeNotifier { bool _disposed = false; + /// Callback to check whether Ctrl modifier lock is currently active. + /// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02). + bool Function()? isCtrlLocked; + + /// Callback to clear Ctrl lock after a key is pressed (one-shot mode). + void Function()? clearCtrlLock; + + /// Callback to check whether Alt modifier lock is currently active. + bool Function()? isAltLocked; + + /// Callback to clear Alt lock after a key is pressed (one-shot mode). + void Function()? clearAltLock; + final _inputBuffer = []; + + /// Exposes buffered input only for lifecycle regression tests. + @visibleForTesting + int get debugBufferedInputCount => _inputBuffer.length; + // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; @@ -38,7 +58,15 @@ class TerminalModel with ChangeNotifier { void Function(int w, int h, int pw, int ph)? onResizeExternal; + /// Called when the terminal session ends (shell exits). + /// The listener (typically TerminalPage) can use this to auto-close the tab/page. + VoidCallback? onClosed; + Future _handleInput(String data) async { + // xterm can complete asynchronous input after the Flutter page has gone + // away. Stop before reading or clearing widget-owned modifier state. + if (_disposed) return; + // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. // - Peer Windows: '\r' works, '\n' is just a newline. @@ -46,13 +74,44 @@ class TerminalModel with ChangeNotifier { // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. // - Peer macOS: same as Linux, raw-mode apps expect '\r' // (https://github.com/rustdesk/rustdesk/issues/14907). - // So on mobile / web-mobile, always normalize a lone '\n' to '\r'. - // We deliberately do not touch multi-character payloads (e.g. pasted text) - // so embedded newlines in pasted content are preserved. - final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); - if (isMobileOrWebMobile && data == '\n') { - data = '\r'; + // So on mobile / web-mobile, normalize the original lone '\n' to '\r' + // before modifier mappings. This keeps Ctrl+J mapped to LF instead of + // having the generated control code rewritten to CR afterward. + // Multi-character keyboard payloads, such as terminal escape sequences, + // remain unchanged. Paste input follows a separate preprocessing path. + final ctrlLocked = isCtrlLocked?.call() ?? false; + final altLocked = isAltLocked?.call() ?? false; + final modifiersActive = ctrlLocked || altLocked; + // Use the same predicate for transformation and consumption. Control keys + // and escape sequences must not silently consume a pending one-shot lock. + final shouldConsumeModifiers = + modifiersActive && shouldApplyTerminalInputModifiers(data); + data = prepareTerminalInputPayload( + data, + // IME soft-keyboard paste prompts currently arrive from xterm as normal + // text input with no paste-origin metadata. Keep them on the keyboard path; + // clipboard-content heuristics can misclassify ordinary typing. + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop), + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + if (shouldConsumeModifiers) { + if (ctrlLocked) clearCtrlLock?.call(); + if (altLocked) clearAltLock?.call(); } + return _sendInputPayload(data); + } + + /// Sends an already prepared payload without applying keyboard semantics. + /// Both normal input and paste use this transport path after their source- + /// specific preprocessing has completed. + Future _sendInputPayload(String data) async { + // Clipboard reads and native sends may complete after the terminal page has + // closed. Never send or re-buffer input once this model is disposed. + if (_disposed) return; + if (_terminalOpened) { // Send user input to remote terminal try { @@ -71,7 +130,8 @@ class TerminalModel with ChangeNotifier { } TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { - terminal = Terminal(maxLines: 10000); + terminal = RustDeskTerminal(maxLines: 10000); + terminal.mouseHandler = const WheelButtonFixMouseHandler(); terminalController = TerminalController(); // Setup terminal callbacks @@ -173,6 +233,18 @@ class TerminalModel with ChangeNotifier { return _handleInput(data); } + Future pasteText(String data) async { + final payload = prepareTerminalInputPayload( + data, + source: TerminalInputSource.paste, + isMobileOrWebMobile: false, + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: false, + altLocked: false, + ); + return _sendInputPayload(payload); + } + Future closeTerminal() async { if (_terminalOpened) { try { @@ -247,6 +319,33 @@ class TerminalModel with ChangeNotifier { } } + static int getExitCodeFromEvt(Map evt) { + if (evt.containsKey('exit_code')) { + final v = evt['exit_code']; + if (v is int) { + // Desktop and mobile send exit_code as an int + return v; + } else if (v is String) { + // Web sends exit_code as a string + final parsed = int.tryParse(v); + if (parsed != null) { + return parsed; + } else { + debugPrint( + '[TerminalModel] Failed to parse exit_code as integer: $v. Expected a numeric string.'); + return 0; + } + } else { + debugPrint( + '[TerminalModel] Unexpected exit_code type: ${v.runtimeType}, value: $v. Expected int or String.'); + return 0; + } + } else { + debugPrint('[TerminalModel] Event does not contain exit_code'); + return 0; + } + } + void handleTerminalResponse(Map evt) { final String? type = evt['type']; final int evtTerminalId = getTerminalIdFromEvt(evt); @@ -469,10 +568,12 @@ class TerminalModel with ChangeNotifier { } void _handleTerminalClosed(Map evt) { - final int exitCode = evt['exit_code'] ?? 0; + final int exitCode = getExitCodeFromEvt(evt); _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n'); _terminalOpened = false; notifyListeners(); + // Auto-close the tab/page + onClosed?.call(); } void _handleTerminalError(Map evt) { @@ -484,6 +585,14 @@ class TerminalModel with ChangeNotifier { void dispose() { if (_disposed) return; _disposed = true; + terminal.onOutput = null; + terminal.onResize = null; + isCtrlLocked = null; + clearCtrlLock = null; + isAltLocked = null; + clearAltLock = null; + onResizeExternal = null; + onClosed = null; // Clear buffers to free memory _inputBuffer.clear(); _pendingOutputChunks.clear(); diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart new file mode 100644 index 00000000000..a6a617488f4 --- /dev/null +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -0,0 +1,42 @@ +import 'package:xterm/xterm.dart'; + +/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift +/// modifier, so strict full-screen apps ignore the report and never scroll. +/// Upstream fix: TerminalStudio/xterm.dart#238. +class WheelButtonFixMouseHandler implements TerminalMouseHandler { + const WheelButtonFixMouseHandler(); + + @override + String? call(TerminalMouseEvent event) { + if (!event.button.isWheel) { + return defaultMouseHandler(event); + } + // Same gate as UpDownMouseHandler: only the scroll modes report a wheel, + // and a wheel release is never reported, so the report is always a press. + if (!event.state.mouseMode.reportScroll || + event.buttonState == TerminalMouseButtonState.up) { + return null; + } + return _reportWheel(event); + } + + String _reportWheel(TerminalMouseEvent event) { + // Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7. + final button = event.button.id - 4; + final x = event.position.x + 1; + final y = event.position.y + 1; + switch (event.state.mouseReportMode) { + case MouseReportMode.normal: + case MouseReportMode.utf: + final limit = + event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015; + final col = x > limit ? '\x00' : String.fromCharCode(32 + x); + final row = y > limit ? '\x00' : String.fromCharCode(32 + y); + return '\x1b[M${String.fromCharCode(32 + button)}$col$row'; + case MouseReportMode.sgr: + return '\x1b[<$button;$x;${y}M'; + case MouseReportMode.urxvt: + return '\x1b[${32 + button};$x;${y}M'; + } + } +} diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index cecb58eaa54..405a9faddc9 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -20,6 +20,9 @@ class UserModel { final RxString avatar = ''.obs; final RxBool isAdmin = false.obs; final RxString networkError = ''.obs; + // True when networkError carries a server-reported error rather than a + // connectivity failure; netWorkErrorWidget hides the network tip then. + final RxBool networkErrorFromServer = false.obs; bool get isLogin => userName.isNotEmpty; String get displayNameOrUserName => displayName.value.trim().isEmpty ? userName.value : displayName.value; @@ -50,6 +53,7 @@ class UserModel { void refreshCurrentUser() async { if (bind.isDisableAccount()) return; networkError.value = ''; + networkErrorFromServer.value = false; final token = bind.mainGetLocalOption(key: 'access_token'); if (token == '') { await updateOtherModels(); @@ -85,6 +89,10 @@ class UserModel { final data = json.decode(decode_http_response(response)); final error = data['error']; if (error != null) { + // The only failure known to come from the server itself, so the + // check-your-network tip does not apply. Flag before the message is + // set in the catch below so rebuilds read a consistent pair. + networkErrorFromServer.value = true; throw error; } @@ -92,6 +100,13 @@ class UserModel { _parseAndUpdateUser(user); } catch (e) { debugPrint('Failed to refreshCurrentUser: $e'); + // Surface failures in the address book / group tabs, which offer a + // retry. Anything not flagged above -- transport errors, non-JSON or + // unexpected-schema bodies (e.g. a filter's block page) -- keeps the + // check-your-network tip. + if (networkError.value.isEmpty) { + networkError.value = e.toString(); + } } finally { refreshingUser = false; await updateOtherModels(); @@ -219,28 +234,32 @@ class UserModel { return loginResponse; } + /// Throws on network failures, non-success responses, and invalid response + /// data. Returns an empty list when no API server is configured or a + /// successful response contains no third-party login options. static Future> queryOidcLoginOptions() async { - try { - final url = await bind.mainGetApiServer(); - if (url.trim().isEmpty) return []; - final resp = await http.get(Uri.parse('$url/api/login-options')); - final List ops = []; - for (final item in jsonDecode(resp.body)) { - ops.add(item as String); - } - for (final item in ops) { - if (item.startsWith('common-oidc/')) { - return jsonDecode(item.substring('common-oidc/'.length)); - } + final url = await bind.mainGetApiServer(); + if (url.trim().isEmpty) return []; + final resp = await http.get(Uri.parse('$url/api/login-options')); + const successStatusCodeStart = 200; + const successStatusCodeEnd = 300; + if (resp.statusCode < successStatusCodeStart || + resp.statusCode >= successStatusCodeEnd) { + throw RequestException( + resp.statusCode, resp.reasonPhrase ?? 'Request failed'); + } + final List ops = []; + for (final item in jsonDecode(resp.body)) { + ops.add(item as String); + } + for (final item in ops) { + if (item.startsWith('common-oidc/')) { + return jsonDecode(item.substring('common-oidc/'.length)); } - return ops - .where((item) => item.startsWith('oidc/')) - .map((item) => {'name': item.substring('oidc/'.length)}) - .toList(); - } catch (e) { - debugPrint( - "queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}"); - return []; } + return ops + .where((item) => item.startsWith('oidc/')) + .map((item) => {'name': item.substring('oidc/'.length)}) + .toList(); } } diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index 5241c3974ff..b65825e5161 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -2,14 +2,18 @@ import 'dart:convert'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'dart:typed_data'; import 'dart:js'; import 'dart:html'; import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart'; import 'package:flutter_hbb/common/widgets/login.dart'; import 'package:flutter_hbb/models/state_model.dart'; +import 'package:flutter_hbb/models/web_video_frame_queue.dart'; import 'package:flutter_hbb/web/bridge.dart'; import 'package:flutter_hbb/common.dart'; @@ -18,6 +22,22 @@ import 'package:uuid/uuid.dart'; final List> mouseListeners = []; final List> keyListeners = []; +// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain +// interop objects (the package language version predates extension types). +// This side owns each frame and must close it quickly: hardware decoders +// stall once their small output frame pool is exhausted. +int _videoFrameWidth(JSObject frame) => + frame.getProperty('displayWidth'.toJS).toDartInt; +int _videoFrameHeight(JSObject frame) => + frame.getProperty('displayHeight'.toJS).toDartInt; +void _closeVideoFrame(JSObject frame) { + try { + frame.callMethod('close'.toJS); + } catch (error) { + debugPrint('VideoFrame.close failed: $error'); + } +} + typedef HandleEvent = Future Function(Map evt); class PlatformFFI { @@ -33,6 +53,13 @@ class PlatformFFI { } PlatformFFI._() { + _videoFrameQueue = WebVideoFrameQueue( + importFrame: _importVideoFrame, + closeFrame: _closeVideoFrame, + disposeImage: (image) => image.dispose(), + onImportError: _handleVideoFrameImportError, + onCallbackError: _handleVideoImageCallbackError, + ); window.document.addEventListener( 'visibilitychange', (event) => { @@ -162,6 +189,46 @@ class PlatformFFI { }; } + late final WebVideoFrameQueue _videoFrameQueue; + + // Zero-readback video path: the JS decoder hands decoded VideoFrames here + // (checking typeof window.onVideoFrame before every frame), and the engine + // imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global + // reverts the JS side to the RGBA readback path. + void setVideoFrameCallback( + Future Function(int, ui.Image, bool Function()) fun) { + _videoFrameQueue.beginSession(fun); + if (!_videoFrameQueue.isEnabled) return; + globalContext.setProperty( + 'onVideoFrame'.toJS, + ((JSNumber display, JSObject frame) { + _videoFrameQueue.submit(display.toDartInt, frame); + }).toJS, + ); + } + + void clearVideoFrameCallback() { + _videoFrameQueue.endSession(); + globalContext.setProperty('onVideoFrame'.toJS, null); + } + + Future _importVideoFrame(JSObject frame) async { + return await ui_web.createImageFromTextureSource(frame, + width: _videoFrameWidth(frame), height: _videoFrameHeight(frame)); + } + + void _handleVideoFrameImportError(Object error, StackTrace stackTrace) { + debugPrintStack( + label: 'createImageFromTextureSource failed, using RGBA path: $error', + stackTrace: stackTrace); + globalContext.setProperty('onVideoFrame'.toJS, null); + } + + void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) { + debugPrintStack( + label: 'video image callback error: $error', stackTrace: stackTrace); + } + void startDesktopWebListener() { mouseListeners.add( window.document.onContextMenu.listen((evt) => evt.preventDefault())); diff --git a/flutter/lib/models/web_video_frame_queue.dart b/flutter/lib/models/web_video_frame_queue.dart new file mode 100644 index 00000000000..b78b69166af --- /dev/null +++ b/flutter/lib/models/web_video_frame_queue.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +typedef VideoFrameImporter = Future Function(Frame frame); +typedef VideoFrameCloser = void Function(Frame frame); +typedef VideoImageDisposer = void Function(Image image); +typedef VideoSessionValidator = bool Function(); +typedef VideoImageCallback = Future Function( + int display, Image image, VideoSessionValidator isCurrentSession); +typedef VideoQueueErrorCallback = void Function( + Object error, StackTrace stackTrace); + +class WebVideoFrameQueue { + WebVideoFrameQueue({ + required VideoFrameImporter importFrame, + required VideoFrameCloser closeFrame, + required VideoImageDisposer disposeImage, + required VideoQueueErrorCallback onImportError, + required VideoQueueErrorCallback onCallbackError, + }) : _importFrame = importFrame, + _closeFrame = closeFrame, + _disposeImage = disposeImage, + _onImportError = onImportError, + _onCallbackError = onCallbackError; + + final VideoFrameImporter _importFrame; + final VideoFrameCloser _closeFrame; + final VideoImageDisposer _disposeImage; + final VideoQueueErrorCallback _onImportError; + final VideoQueueErrorCallback _onCallbackError; + final Map> _pending = {}; + + VideoImageCallback? _callback; + int _generation = 0; + bool _processing = false; + bool _enabled = true; + + bool get isEnabled => _enabled; + + void beginSession(VideoImageCallback callback) { + _invalidateSession(); + _enabled = true; + _callback = callback; + } + + void endSession() { + _invalidateSession(); + _callback = null; + } + + void _invalidateSession() { + _generation++; + for (final queued in _pending.values) { + _closeFrame(queued.frame); + } + _pending.clear(); + } + + bool submit(int display, Frame frame) { + if (!_enabled || _callback == null) { + _closeFrame(frame); + return false; + } + final replaced = _pending.remove(display); + if (replaced != null) { + _closeFrame(replaced.frame); + } + _pending[display] = _QueuedFrame(display, frame, _generation); + _startProcessing(); + return true; + } + + void _startProcessing() { + if (_processing) return; + _processing = true; + unawaited(Future(_process)); + } + + Future _process() async { + while (_pending.isNotEmpty) { + final display = _pending.keys.first; + final queued = _pending.remove(display)!; + if (!_enabled || queued.generation != _generation) { + _closeFrame(queued.frame); + continue; + } + await _importAndDeliver(queued); + } + _processing = false; + } + + Future _importAndDeliver(_QueuedFrame queued) async { + Image? image; + try { + image = await _importFrame(queued.frame); + } catch (error, stackTrace) { + if (queued.generation == _generation) { + _enabled = false; + _onImportError(error, stackTrace); + } + } finally { + _closeFrame(queued.frame); + } + if (image != null) { + await _deliver(queued, image); + } + } + + Future _deliver(_QueuedFrame queued, Image image) async { + final callback = _callback; + bool isCurrentSession() => + _enabled && + queued.generation == _generation && + identical(callback, _callback); + if (!isCurrentSession() || callback == null) { + _disposeImage(image); + return; + } + try { + await callback(queued.display, image, isCurrentSession); + } catch (error, stackTrace) { + _disposeImage(image); + _onCallbackError(error, stackTrace); + } + } +} + +class _QueuedFrame { + const _QueuedFrame(this.display, this.frame, this.generation); + + final int display; + final Frame frame; + final int generation; +} diff --git a/flutter/lib/plugin/common.dart b/flutter/lib/plugin/common.dart deleted file mode 100644 index d984c68ea7b..00000000000 --- a/flutter/lib/plugin/common.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -typedef PluginId = String; - -// ui location -const String kLocationHostMainPlugin = 'host|main|settings|plugin'; -const String kLocationClientRemoteToolbarDisplay = - 'client|remote|toolbar|display'; - -class MsgFromUi { - String id; - String name; - String location; - String key; - String value; - String action; - - MsgFromUi({ - required this.id, - required this.name, - required this.location, - required this.key, - required this.value, - required this.action, - }); - - Map toJson() { - return { - 'id': id, - 'name': name, - 'location': location, - 'key': key, - 'value': value, - 'action': action, - }; - } - - @override - String toString() { - return jsonEncode(toJson()); - } -} diff --git a/flutter/lib/plugin/event.dart b/flutter/lib/plugin/event.dart deleted file mode 100644 index 29a2ae44c53..00000000000 --- a/flutter/lib/plugin/event.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; - -void handlePluginEvent( - Map evt, - Function(Map e) handleMsgBox, -) { - Map? content; - try { - content = json.decode(evt['content']); - } catch (e) { - debugPrint( - 'Json decode plugin event content failed: $e, ${evt['content']}'); - } - if (content?['t'] == 'MsgBox') { - handleMsgBox(content?['c']); - } -} diff --git a/flutter/lib/plugin/handlers.dart b/flutter/lib/plugin/handlers.dart deleted file mode 100644 index c85f4dfcacf..00000000000 --- a/flutter/lib/plugin/handlers.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; - -import 'package:ffi/ffi.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/plugin/ui_manager.dart'; -import 'package:flutter_hbb/plugin/utils/dialogs.dart'; - -abstract class NativeHandler { - bool onEvent(Map evt); -} - -typedef OnSelectPeersCallback = Bool Function(Int returnCode, - Pointer data, Uint64 dataLength, Pointer userData); -typedef OnSelectPeersCallbackDart = bool Function( - int returnCode, Pointer data, int dataLength, Pointer userData); - -class NativeUiHandler extends NativeHandler { - NativeUiHandler._(); - - static NativeUiHandler instance = NativeUiHandler._(); - - @override - bool onEvent(Map evt) { - final name = evt['name']; - final action = evt['action']; - if (name != "native_ui") { - return false; - } - switch (action) { - case "select_peers": - int cb = evt['cb']; - int userData = evt['user_data'] ?? 0; - final cbFuncNative = Pointer.fromAddress(cb) - .cast>(); - final cbFuncDart = cbFuncNative.asFunction(); - onSelectPeers(cbFuncDart, userData); - break; - case "register_ui_entry": - int cb = evt['on_tap_cb']; - int userData = evt['user_data'] ?? 0; - String title = evt['title'] ?? ""; - final cbFuncNative = Pointer.fromAddress(cb) - .cast>(); - final cbFuncDart = cbFuncNative.asFunction(); - onRegisterUiEntry(title, cbFuncDart, userData); - break; - default: - return false; - } - return true; - } - - void onSelectPeers(OnSelectPeersCallbackDart cb, int userData) async { - showPeerSelectionDialog(onPeersCallback: (peers) { - String json = jsonEncode( { - "peers": peers - }); - final native = json.toNativeUtf8(); - cb(0, native.cast(), native.length, Pointer.fromAddress(userData)); - malloc.free(native); - }); - } - - void onRegisterUiEntry(String title, OnSelectPeersCallbackDart cbFuncDart, int userData) { - Widget widget = InkWell( - child: Container( - height: 25.0, - child: Row( - children: [ - Expanded(child: Text(title)), - Icon(Icons.chevron_right_rounded, size: 12.0,) - ], - ), - ), - ); - PluginUiManager.instance.registerEntry(title, widget); - } -} diff --git a/flutter/lib/plugin/manager.dart b/flutter/lib/plugin/manager.dart deleted file mode 100644 index f58a1a54e27..00000000000 --- a/flutter/lib/plugin/manager.dart +++ /dev/null @@ -1,319 +0,0 @@ -// The plugin manager is a singleton class that manages the plugins. -// 1. It merge metadata and the desc of plugins. - -import 'dart:convert'; -import 'dart:collection'; -import 'package:flutter/material.dart'; - -const String kValueTrue = '1'; -const String kValueFalse = '0'; - -class ConfigItem { - String key; - String description; - String defaultValue; - - ConfigItem(this.key, this.defaultValue, this.description); - ConfigItem.fromJson(Map json) - : key = json['key'] ?? '', - description = json['description'] ?? '', - defaultValue = json['default'] ?? ''; - - static String get trueValue => kValueTrue; - static String get falseValue => kValueFalse; - static bool isTrue(String value) => value == kValueTrue; - static bool isFalse(String value) => value == kValueFalse; -} - -class UiType { - String key; - String text; - String tooltip; - String action; - - UiType(this.key, this.text, this.tooltip, this.action); - - UiType.fromJson(Map json) - : key = json['key'] ?? '', - text = json['text'] ?? '', - tooltip = json['tooltip'] ?? '', - action = json['action'] ?? ''; - - static UiType? create(Map json) { - if (json['t'] == 'Button') { - return UiButton.fromJson(json['c']); - } else if (json['t'] == 'Checkbox') { - return UiCheckbox.fromJson(json['c']); - } else { - return null; - } - } -} - -class UiButton extends UiType { - String icon; - - UiButton( - {required String key, - required String text, - required this.icon, - required String tooltip, - required String action}) - : super(key, text, tooltip, action); - - UiButton.fromJson(Map json) - : icon = json['icon'] ?? '', - super.fromJson(json); -} - -class UiCheckbox extends UiType { - UiCheckbox( - {required String key, - required String text, - required String tooltip, - required String action}) - : super(key, text, tooltip, action); - - UiCheckbox.fromJson(Map json) : super.fromJson(json); -} - -class Location { - // location key: - // host|main|settings|plugin - // client|remote|toolbar|display - HashMap ui; - - Location(this.ui); - Location.fromJson(Map json) : ui = HashMap() { - (json['ui'] as Map).forEach((key, value) { - var ui = UiType.create(value); - if (ui != null) { - this.ui[ui.key] = ui; - } - }); - } -} - -class PublishInfo { - PublishInfo({ - required this.lastReleased, - required this.published, - }); - - final DateTime lastReleased; - final DateTime published; -} - -class Meta { - Meta({ - required this.id, - required this.name, - required this.version, - required this.description, - required this.author, - required this.home, - required this.license, - required this.publishInfo, - required this.source, - }); - - final String id; - final String name; - final String version; - final String description; - final String author; - final String home; - final String license; - final PublishInfo publishInfo; - final String source; -} - -class SourceInfo { - String name; // 1. RustDesk github 2. Local - String url; - String description; - - SourceInfo({ - required this.name, - required this.url, - required this.description, - }); -} - -class PluginInfo with ChangeNotifier { - SourceInfo sourceInfo; - Meta meta; - String installedVersion; // It is empty if not installed. - String failedMsg; - String invalidReason; // It is empty if valid. - - PluginInfo({ - required this.sourceInfo, - required this.meta, - required this.installedVersion, - required this.invalidReason, - this.failedMsg = '', - }); - - bool get installed => installedVersion.isNotEmpty; - bool get needUpdate => installed && installedVersion != meta.version; - - void setInstall(String msg) { - if (msg == "finished") { - msg = ''; - } - failedMsg = msg; - if (msg.isEmpty) { - installedVersion = meta.version; - } - notifyListeners(); - } - - void setUninstall(String msg) { - failedMsg = msg; - if (msg.isEmpty) { - installedVersion = ''; - } - notifyListeners(); - } -} - -class PluginManager with ChangeNotifier { - String failedReason = ''; // The reason of failed to load plugins. - final List _plugins = []; - - PluginManager._(); - static final PluginManager _instance = PluginManager._(); - static PluginManager get instance => _instance; - - List get plugins => _plugins; - - PluginInfo? getPlugin(String id) { - for (var p in _plugins) { - if (p.meta.id == id) { - return p; - } - } - return null; - } - - void handleEvent(Map evt) { - if (evt['plugin_list'] != null) { - _handlePluginList(evt['plugin_list']); - } else if (evt['plugin_install'] != null && evt['id'] != null) { - _handlePluginInstall(evt['id'], evt['plugin_install']); - } else if (evt['plugin_uninstall'] != null && evt['id'] != null) { - _handlePluginUninstall(evt['id'], evt['plugin_uninstall']); - } else { - debugPrint('Failed to handle manager event: $evt'); - } - } - - void _sortPlugins() { - plugins.sort((a, b) { - if (a.installed) { - return -1; - } else if (b.installed) { - return 1; - } else { - return 0; - } - }); - } - - void _handlePluginList(String pluginList) { - _plugins.clear(); - try { - for (var p in json.decode(pluginList) as List) { - final plugin = _getPluginFromEvent(p); - if (plugin == null) { - continue; - } - _plugins.add(plugin); - } - } catch (e) { - debugPrint('Failed to decode $e, plugin list \'$pluginList\''); - } - _sortPlugins(); - notifyListeners(); - } - - void _handlePluginInstall(String id, String msg) { - debugPrint('Plugin \'$id\' install msg $msg'); - for (var i = 0; i < _plugins.length; i++) { - if (_plugins[i].meta.id == id) { - _plugins[i].setInstall(msg); - _sortPlugins(); - notifyListeners(); - return; - } - } - } - - void _handlePluginUninstall(String id, String msg) { - debugPrint('Plugin \'$id\' uninstall msg $msg'); - for (var i = 0; i < _plugins.length; i++) { - if (_plugins[i].meta.id == id) { - _plugins[i].setUninstall(msg); - _sortPlugins(); - notifyListeners(); - return; - } - } - } - - PluginInfo? _getPluginFromEvent(Map evt) { - final s = evt['source']; - assert(s != null, 'Source is null'); - if (s == null) { - return null; - } - final source = SourceInfo( - name: s['name'], - url: s['url'] ?? '', - description: s['description'] ?? '', - ); - - final m = evt['meta']; - assert(m != null, 'Meta is null'); - if (m == null) { - return null; - } - - late DateTime lastReleased; - late DateTime published; - try { - lastReleased = DateTime.parse( - m['publish_info']?['last_released'] ?? '1970-01-01T00+00:00'); - } catch (e) { - lastReleased = DateTime.utc(1970); - } - try { - published = DateTime.parse( - m['publish_info']?['published'] ?? '1970-01-01T00+00:00'); - } catch (e) { - published = DateTime.utc(1970); - } - - final meta = Meta( - id: m['id'], - name: m['name'], - version: m['version'], - description: m['description'] ?? '', - author: m['author'], - home: m['home'] ?? '', - license: m['license'] ?? '', - source: m['source'] ?? '', - publishInfo: - PublishInfo(lastReleased: lastReleased, published: published), - ); - return PluginInfo( - sourceInfo: source, - meta: meta, - installedVersion: evt['installed_version'], - invalidReason: evt['invalid_reason'] ?? '', - ); - } -} - -PluginManager get pluginManager => PluginManager.instance; diff --git a/flutter/lib/plugin/model.dart b/flutter/lib/plugin/model.dart deleted file mode 100644 index 4fc024e4c51..00000000000 --- a/flutter/lib/plugin/model.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import './common.dart'; -import './manager.dart'; - -final Map _locationModels = {}; -final Map _optionModels = {}; - -class OptionModel with ChangeNotifier { - String? v; - - String? get value => v; - set value(String? v) { - this.v = v; - notifyListeners(); - } - - static String key(String location, PluginId id, String peer, String k) => - '$location|$id|$peer|$k'; -} - -class PluginModel with ChangeNotifier { - final List uiList = []; - final Map opts = {}; - - void add(List uiList) { - bool found = false; - for (var ui in uiList) { - for (int i = 0; i < this.uiList.length; i++) { - if (this.uiList[i].key == ui.key) { - this.uiList[i] = ui; - found = true; - } - } - if (!found) { - this.uiList.add(ui); - } - } - notifyListeners(); - } - - String? getOpt(String key) => opts.remove(key); - - bool get isEmpty => uiList.isEmpty; -} - -class LocationModel with ChangeNotifier { - final Map pluginModels = {}; - - void add(PluginId id, List uiList) { - if (pluginModels[id] != null) { - pluginModels[id]!.add(uiList); - } else { - var model = PluginModel(); - model.add(uiList); - pluginModels[id] = model; - notifyListeners(); - } - } - - void clear() { - pluginModels.clear(); - notifyListeners(); - } - - void remove(PluginId id) { - pluginModels.remove(id); - notifyListeners(); - } - - bool get isEmpty => pluginModels.isEmpty; -} - -void addLocationUi(String location, PluginId id, List uiList) { - if (_locationModels[location] == null) { - _locationModels[location] = LocationModel(); - } - _locationModels[location]?.add(id, uiList); -} - -LocationModel? getLocationModel(String location) => _locationModels[location]; - -PluginModel? getPluginModel(String location, PluginId id) => - _locationModels[location]?.pluginModels[id]; - -void clearPlugin(PluginId pluginId) { - for (var element in _locationModels.values) { - element.remove(pluginId); - } -} - -void clearLocations() { - for (var element in _locationModels.values) { - element.clear(); - } -} - -OptionModel getOptionModel( - String location, PluginId pluginId, String peer, String key) { - final k = OptionModel.key(location, pluginId, peer, key); - if (_optionModels[k] == null) { - _optionModels[k] = OptionModel(); - } - return _optionModels[k]!; -} - -void updateOption( - String location, PluginId id, String peer, String key, String value) { - final k = OptionModel.key(location, id, peer, key); - _optionModels[k]?.value = value; -} diff --git a/flutter/lib/plugin/ui_manager.dart b/flutter/lib/plugin/ui_manager.dart deleted file mode 100644 index 45accf65080..00000000000 --- a/flutter/lib/plugin/ui_manager.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -class PluginUiManager { - PluginUiManager._(); - - static PluginUiManager instance = PluginUiManager._(); - - Map entries = {}; - - void registerEntry(String key, Widget widget) { - entries[key] = widget; - } - - void unregisterEntry(String key) { - entries.remove(key); - } -} \ No newline at end of file diff --git a/flutter/lib/plugin/utils/dialogs.dart b/flutter/lib/plugin/utils/dialogs.dart deleted file mode 100644 index 6fdb86ab41c..00000000000 --- a/flutter/lib/plugin/utils/dialogs.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/common.dart'; - -void showPeerSelectionDialog( - {bool singleSelection = false, - required Function(List) onPeersCallback}) async { - // load recent peers, we can directly use the peers in `gFFI.recentPeersModel`. - // The plugin is not used for now, so just left it empty here. - final peers = ''; - if (peers.isEmpty) { - // debugPrint("load recent peers failed."); - return; - } - - Map map = jsonDecode(peers); - List peersList = map['peers'] ?? []; - final selected = List.empty(growable: true); - - submit() async { - onPeersCallback.call(selected); - } - - gFFI.dialogManager.show((setState, close, context) { - return CustomAlertDialog( - title: - Text(translate(singleSelection ? "Select peers" : "Select a peer")), - content: SizedBox( - height: 300.0, - child: ListView.builder( - itemBuilder: (context, index) { - final Map peer = peersList[index]; - final String platform = peer['platform'] ?? ""; - final String id = peer['id'] ?? ""; - final String alias = peer['alias'] ?? ""; - return GestureDetector( - onTap: () { - setState(() { - if (selected.contains(id)) { - selected.remove(id); - } else { - selected.add(id); - } - }); - }, - child: Container( - key: ValueKey(index), - height: 50.0, - decoration: BoxDecoration( - color: Theme.of(context).highlightColor, - borderRadius: BorderRadius.circular(12.0)), - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - margin: EdgeInsets.symmetric(vertical: 4.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - // platform - SizedBox( - width: 8.0, - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - getPlatformImage(platform, size: 34.0), - ], - ), - SizedBox( - width: 8.0, - ), - // id/alias - Expanded(child: Text(alias.isEmpty ? id : alias)), - ], - ), - ), - ); - }, - itemCount: peersList.length, - itemExtent: 50.0, - ), - ), - onSubmit: submit, - ); - }); -} diff --git a/flutter/lib/plugin/widgets/desc_ui.dart b/flutter/lib/plugin/widgets/desc_ui.dart deleted file mode 100644 index 10c231f989f..00000000000 --- a/flutter/lib/plugin/widgets/desc_ui.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/models/model.dart'; -import 'package:provider/provider.dart'; -import 'package:get/get.dart'; -// to-do: do not depend on desktop -import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; - -import '../manager.dart'; -import '../model.dart'; -import '../common.dart'; - -// dup to flutter\lib\desktop\pages\desktop_setting_page.dart -const double _kCheckBoxLeftMargin = 10; - -class LocationItem extends StatelessWidget { - final String peerId; - final FFI ffi; - final String location; - final LocationModel locationModel; - final bool isMenu; - - LocationItem({ - Key? key, - required this.peerId, - required this.ffi, - required this.location, - required this.locationModel, - required this.isMenu, - }) : super(key: key); - - bool get isEmpty => locationModel.isEmpty; - - static Widget createLocationItem( - String peerId, FFI ffi, String location, bool isMenu) { - final model = getLocationModel(location); - return model == null - ? Container() - : LocationItem( - peerId: peerId, - ffi: ffi, - location: location, - locationModel: model, - isMenu: isMenu, - ); - } - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: locationModel, - child: Consumer(builder: (context, model, child) { - return Column( - children: model.pluginModels.entries - .map((entry) => _buildPluginItem(entry.key, entry.value)) - .toList(), - ); - }), - ); - } - - Widget _buildPluginItem(PluginId id, PluginModel model) => PluginItem( - pluginId: id, - peerId: peerId, - ffi: ffi, - location: location, - pluginModel: model, - isMenu: isMenu, - ); -} - -class PluginItem extends StatelessWidget { - final PluginId pluginId; - final String peerId; - final FFI? ffi; - final String location; - final PluginModel pluginModel; - final bool isMenu; - - PluginItem({ - Key? key, - required this.pluginId, - required this.peerId, - this.ffi, - required this.location, - required this.pluginModel, - required this.isMenu, - }) : super(key: key); - - bool get isEmpty => pluginModel.isEmpty; - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: pluginModel, - child: Consumer( - builder: (context, pluginModel, child) { - return Column( - children: pluginModel.uiList.map((ui) => _buildItem(ui)).toList(), - ); - }, - ), - ); - } - - Widget _buildItem(UiType ui) { - Widget? child; - switch (ui.runtimeType) { - case UiButton: - if (isMenu) { - if (ffi != null) { - child = _buildMenuButton(ui as UiButton, ffi!); - } - } else { - child = _buildButton(ui as UiButton); - } - break; - case UiCheckbox: - if (isMenu) { - if (ffi != null) { - child = _buildCheckboxMenuButton(ui as UiCheckbox, ffi!); - } - } else { - child = _buildCheckbox(ui as UiCheckbox); - } - break; - default: - break; - } - // to-do: add plugin icon and tooltip - return child ?? Container(); - } - - Widget _buildButton(UiButton ui) { - return TextButton( - onPressed: () => bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key), - ), - child: Text(ui.text), - ); - } - - Widget _buildCheckbox(UiCheckbox ui) { - getChild(OptionModel model) { - final v = _getOption(model, ui.key); - if (v == null) { - // session or plugin not found - return Container(); - } - - onChanged(bool value) { - bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key, v: value), - ); - } - - final value = ConfigItem.isTrue(v); - return GestureDetector( - child: Row( - children: [ - Checkbox( - value: value, - onChanged: (_) => onChanged(!value), - ).marginOnly(right: 5), - Expanded( - child: Text(translate(ui.text)), - ) - ], - ).marginOnly(left: _kCheckBoxLeftMargin), - onTap: () => onChanged(!value), - ); - } - - return ChangeNotifierProvider.value( - value: getOptionModel(location, pluginId, peerId, ui.key), - child: Consumer( - builder: (context, model, child) => getChild(model), - ), - ); - } - - Widget _buildCheckboxMenuButton(UiCheckbox ui, FFI ffi) { - getChild(OptionModel model) { - final v = _getOption(model, ui.key); - if (v == null) { - // session or plugin not found - return Container(); - } - return CkbMenuButton( - value: ConfigItem.isTrue(v), - onChanged: (v) { - if (v != null) { - bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key, v: v), - ); - } - }, - // to-do: RustDesk translate or plugin translate ? - child: Text(ui.text), - ffi: ffi, - ); - } - - return ChangeNotifierProvider.value( - value: getOptionModel(location, pluginId, peerId, ui.key), - child: Consumer( - builder: (context, model, child) => getChild(model), - ), - ); - } - - Widget _buildMenuButton(UiButton ui, FFI ffi) { - return MenuButton( - onPressed: () => bind.pluginEvent( - id: pluginId, - peer: peerId, - event: _makeEvent(ui.key), - ), - // to-do: support trailing icon, but it will cause tree shake error. - // ``` - // This application cannot tree shake icons fonts. It has non-constant instances of IconData at the following locations: - // Target release_macos_bundle_flutter_assets failed: Exception: Avoid non-constant invocations of IconData or try to build again with --no-tree-shake-icons. - // ``` - // - // trailingIcon: Icon( - // IconData(int.parse(ui.icon, radix: 16), fontFamily: 'MaterialIcons')), - // - // to-do: RustDesk translate or plugin translate ? - child: Text(ui.text), - ffi: ffi, - ); - } - - Uint8List _makeEvent( - String key, { - bool? v, - }) { - final event = MsgFromUi( - id: pluginId, - name: pluginManager.getPlugin(pluginId)?.meta.name ?? '', - location: location, - key: key, - value: - v != null ? (v ? ConfigItem.trueValue : ConfigItem.falseValue) : '', - action: '', - ); - return Uint8List.fromList(event.toString().codeUnits); - } - - String? _getOption(OptionModel model, String key) { - var v = model.value; - if (v == null) { - try { - if (peerId.isEmpty) { - v = bind.pluginGetSharedOption(id: pluginId, key: key); - } else { - v = bind.pluginGetSessionOption(id: pluginId, peer: peerId, key: key); - } - } catch (e) { - debugPrint('Failed to get option "$key", $e'); - v = null; - } - } - return v; - } -} - -void handleReloading(Map evt) { - if (evt['id'] == null || evt['location'] == null) { - return; - } - try { - final uiList = []; - for (var e in json.decode(evt['ui'] as String)) { - final ui = UiType.create(e); - if (ui != null) { - uiList.add(ui); - } - } - if (uiList.isNotEmpty) { - addLocationUi(evt['location']!, evt['id']!, uiList); - } - } catch (e) { - debugPrint('Failed handleReloading, json decode of ui, $e '); - } -} - -void handleOption(Map evt) { - updateOption( - evt['location'], evt['id'], evt['peer'] ?? '', evt['key'], evt['value']); -} diff --git a/flutter/lib/plugin/widgets/desktop_settings.dart b/flutter/lib/plugin/widgets/desktop_settings.dart deleted file mode 100644 index 232df001f1b..00000000000 --- a/flutter/lib/plugin/widgets/desktop_settings.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; -import 'package:flutter_hbb/plugin/model.dart'; -import 'package:flutter_hbb/plugin/common.dart'; -import 'package:get/get.dart'; - -import '../manager.dart'; -import './desc_ui.dart'; - -// to-do: use settings from desktop_setting_page.dart -const double _kCardFixedWidth = 540; -const double _kCardLeftMargin = 15; -const double _kContentHMargin = 15; -const double _kTitleFontSize = 20; -const double _kVersionFontSize = 12; - -class DesktopSettingsCard extends StatefulWidget { - final PluginInfo plugin; - DesktopSettingsCard({ - Key? key, - required this.plugin, - }) : super(key: key); - - @override - State createState() => _DesktopSettingsCardState(); -} - -class _DesktopSettingsCardState extends State { - PluginInfo get plugin => widget.plugin; - bool get installed => plugin.installed; - - bool isEnabled = false; - - @override - Widget build(BuildContext context) { - isEnabled = bind.pluginIsEnabled(id: plugin.meta.id); - return Row( - children: [ - Flexible( - child: SizedBox( - width: _kCardFixedWidth, - child: Card( - child: Column( - children: [ - header(), - body(), - ], - ).marginOnly(bottom: 10), - ).marginOnly(left: _kCardLeftMargin, top: 15), - ), - ), - ], - ); - } - - Widget header() { - return Row( - children: [ - headerNameVersion(), - headerInstallEnable(), - ], - ).marginOnly( - left: _kContentHMargin, - top: 10, - bottom: 10, - right: _kContentHMargin, - ); - } - - Widget headerNameVersion() { - return Expanded( - child: Row( - children: [ - Text( - widget.plugin.meta.name, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: _kTitleFontSize, - ), - ), - SizedBox( - width: 5, - ), - Text( - plugin.meta.version, - textAlign: TextAlign.start, - style: const TextStyle( - fontSize: _kVersionFontSize, - ), - ) - ], - ), - ); - } - - Widget headerButton(String label, VoidCallback onPressed) { - return Container( - child: ElevatedButton( - onPressed: onPressed, - child: Text(translate(label)), - ), - ); - } - - Widget headerInstallEnable() { - final installButton = headerButton( - installed ? 'Uninstall' : 'Install', - () { - bind.pluginInstall( - id: plugin.meta.id, - b: !installed, - ); - }, - ); - - if (installed) { - final updateButton = plugin.needUpdate - ? headerButton('Update', () { - bind.pluginInstall( - id: plugin.meta.id, - b: !installed, - ); - }) - : Container(); - - final enableButton = !installed - ? Container() - : headerButton(isEnabled ? 'Disable' : 'Enable', () { - if (isEnabled) { - clearPlugin(plugin.meta.id); - } - bind.pluginEnable(id: plugin.meta.id, v: !isEnabled); - setState(() {}); - }); - return Row( - children: [ - updateButton, - SizedBox( - width: 10, - ), - installButton, - SizedBox( - width: 10, - ), - enableButton, - ], - ); - } else { - return installButton; - } - } - - Widget body() { - return Column(children: [ - author(), - description(), - more(), - ]).marginOnly( - left: _kCardLeftMargin, - top: 4, - right: _kContentHMargin, - ); - } - - Widget author() { - return Align( - alignment: Alignment.centerLeft, - child: Text(plugin.meta.author), - ); - } - - Widget description() { - return Align( - alignment: Alignment.centerLeft, - child: Text(plugin.meta.description), - ); - } - - Widget more() { - if (!(installed && isEnabled)) { - return Container(); - } - - final List children = []; - final model = getPluginModel(kLocationHostMainPlugin, plugin.meta.id); - if (model != null) { - children.add(PluginItem( - pluginId: plugin.meta.id, - peerId: '', - location: kLocationHostMainPlugin, - pluginModel: model, - isMenu: false, - )); - } - return ExpansionTile( - title: Text('Options'), - controlAffinity: ListTileControlAffinity.leading, - children: children, - ); - } -} diff --git a/flutter/lib/utils/http_service.dart b/flutter/lib/utils/http_service.dart index 1618e25ff75..9c9624b56ca 100644 --- a/flutter/lib/utils/http_service.dart +++ b/flutter/lib/utils/http_service.dart @@ -44,32 +44,51 @@ class HttpService { return _parseHttpResponse(resJson); } + // Bounds only the pure-Dart branch below, which the OS would otherwise + // let hang forever (e.g. a black-holed TLS handshake), see #15700. + // The Rust branch has its own 12s-per-attempt timeouts and must be + // awaited to completion: a Dart-side timeout there would race the + // URL-keyed ASYNC_HTTP_STATUS entry of the abandoned request. + static const _requestTimeout = Duration(seconds: 30); + Future _pollFlutterHttp( Uri url, HttpMethod method, { Map? headers, dynamic body, }) async { - var response = http.Response('', 400); - - switch (method) { - case HttpMethod.get: - response = await http.get(url, headers: headers); - break; - case HttpMethod.post: - response = await http.post(url, headers: headers, body: body); - break; - case HttpMethod.put: - response = await http.put(url, headers: headers, body: body); - break; - case HttpMethod.delete: - response = await http.delete(url, headers: headers, body: body); - break; - default: - throw Exception('Unsupported HTTP method'); - } + final client = http.Client(); + try { + var response = http.Response('', 400); - return response; + switch (method) { + case HttpMethod.get: + response = + await client.get(url, headers: headers).timeout(_requestTimeout); + break; + case HttpMethod.post: + response = await client + .post(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + case HttpMethod.put: + response = await client + .put(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + case HttpMethod.delete: + response = await client + .delete(url, headers: headers, body: body) + .timeout(_requestTimeout); + break; + default: + throw Exception('Unsupported HTTP method'); + } + + return response; + } finally { + client.close(); + } } Future _pollForResponse(String url) async { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 2df0b3426a3..b59c769dafb 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1644,78 +1644,6 @@ class RustdeskImpl { throw UnimplementedError("sendUrlScheme"); } - Future pluginEvent( - {required String id, - required String peer, - required Uint8List event, - dynamic hint}) { - throw UnimplementedError("pluginEvent"); - } - - Stream pluginRegisterEventStream( - {required String id, dynamic hint}) { - throw UnimplementedError("pluginRegisterEventStream"); - } - - String? pluginGetSessionOption( - {required String id, - required String peer, - required String key, - dynamic hint}) { - throw UnimplementedError("pluginGetSessionOption"); - } - - Future pluginSetSessionOption( - {required String id, - required String peer, - required String key, - required String value, - dynamic hint}) { - throw UnimplementedError("pluginSetSessionOption"); - } - - String? pluginGetSharedOption( - {required String id, required String key, dynamic hint}) { - throw UnimplementedError("pluginGetSharedOption"); - } - - Future pluginSetSharedOption( - {required String id, - required String key, - required String value, - dynamic hint}) { - throw UnimplementedError("pluginSetSharedOption"); - } - - Future pluginReload({required String id, dynamic hint}) { - throw UnimplementedError("pluginReload"); - } - - void pluginEnable({required String id, required bool v, dynamic hint}) { - throw UnimplementedError("pluginEnable"); - } - - bool pluginIsEnabled({required String id, dynamic hint}) { - throw UnimplementedError("pluginIsEnabled"); - } - - bool pluginFeatureIsEnabled({dynamic hint}) { - throw UnimplementedError("pluginFeatureIsEnabled"); - } - - Future pluginSyncUi({required String syncTo, dynamic hint}) { - throw UnimplementedError("pluginSyncUi"); - } - - Future pluginListReload({dynamic hint}) { - throw UnimplementedError("pluginListReload"); - } - - Future pluginInstall( - {required String id, required bool b, dynamic hint}) { - throw UnimplementedError("pluginInstall"); - } - bool isSupportMultiUiSession({required String version, dynamic hint}) { return versionToNumber(v: version) > versionToNumber(v: '1.2.4'); } @@ -1914,6 +1842,15 @@ class RustdeskImpl { throw UnimplementedError("sessionHandleScreenshot"); } + Future sessionSetCommon( + {required UuidValue sessionId, required String key, required String value, dynamic hint}) { + js.context.callMethod('setByName', [ + 'common', + jsonEncode({'name': key, 'value': value}) + ]); + return Future.value(); + } + String? sessionGetCommonSync( {required UuidValue sessionId, required String key, diff --git a/flutter/lib/web/dummy.dart b/flutter/lib/web/dummy.dart index b9e3b80b6ed..0063c38bdea 100644 --- a/flutter/lib/web/dummy.dart +++ b/flutter/lib/web/dummy.dart @@ -12,3 +12,5 @@ Future webSendLocalFiles( required bool isRemote}) { throw UnimplementedError("webSendLocalFiles"); } + +Future loadLocalTerminalFontIfNeeded() async {} diff --git a/flutter/lib/web/plugin/handlers.dart b/flutter/lib/web/plugin/handlers.dart deleted file mode 100644 index f159ce9dd46..00000000000 --- a/flutter/lib/web/plugin/handlers.dart +++ /dev/null @@ -1,14 +0,0 @@ -abstract class NativeHandler { - bool onEvent(Map evt); -} - -class NativeUiHandler extends NativeHandler { - NativeUiHandler._(); - - static NativeUiHandler instance = NativeUiHandler._(); - - @override - bool onEvent(Map evt) { - throw UnimplementedError(); - } -} diff --git a/flutter/lib/web/terminal_font.dart b/flutter/lib/web/terminal_font.dart new file mode 100644 index 00000000000..964924e4c75 --- /dev/null +++ b/flutter/lib/web/terminal_font.dart @@ -0,0 +1,33 @@ +import 'dart:html' as html; +import 'dart:js' as js; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +bool _loadRequested = false; + +/// When Google CDNs are unreachable, `index.html` sets +/// `window.rustdeskLocalFonts` and `GoogleFonts.robotoMono()` cannot download +/// the terminal font. Load the copy bundled with the web app instead, +/// registered under the family name google_fonts gives the terminal's +/// TextStyle ('RobotoMono_regular'). +Future loadLocalTerminalFontIfNeeded() async { + if (_loadRequested || js.context['rustdeskLocalFonts'] != true) { + return; + } + _loadRequested = true; + try { + final req = await html.HttpRequest.request( + 'fonts/RobotoMono-Regular.ttf', + responseType: 'arraybuffer', + ); + final data = ByteData.view(req.response as ByteBuffer); + final loader = FontLoader('RobotoMono_regular') + ..addFont(Future.value(data)); + await loader.load(); + } catch (e) { + _loadRequested = false; + debugPrint('Failed to load bundled Roboto Mono: $e'); + } +} diff --git a/flutter/linux/main.cc b/flutter/linux/main.cc index a7c0419c9dd..a4621767a6c 100644 --- a/flutter/linux/main.cc +++ b/flutter/linux/main.cc @@ -1,4 +1,8 @@ #include +#include +#include +#include +#include #include "my_application.h" #define RUSTDESK_LIB_PATH "librustdesk.so" @@ -7,8 +11,36 @@ bool gIsConnectionManager = false; void print_help_install_pkg(const char* so); +// The bundle keeps the core library at lib/librustdesk.so next to the +// executable. Resolve that path explicitly instead of relying on the +// runner's RPATH, which repackaged installs may strip. +// https://github.com/rustdesk/rustdesk/discussions/14407 +static void* dlopen_bundled_lib() { + char exe_path[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); + if (len <= 0 || len >= (ssize_t)(sizeof(exe_path) - 1)) return nullptr; + exe_path[len] = '\0'; + char* last_slash = strrchr(exe_path, '/'); + if (last_slash == nullptr) return nullptr; + *last_slash = '\0'; + char lib_path[PATH_MAX + sizeof("/lib/" RUSTDESK_LIB_PATH)]; + snprintf(lib_path, sizeof(lib_path), "%s/lib/%s", exe_path, RUSTDESK_LIB_PATH); + if (access(lib_path, F_OK) != 0) return nullptr; + void* librustdesk = dlopen(lib_path, RTLD_LAZY); + if (!librustdesk) { + char* error = dlerror(); + if (error != nullptr) { + fprintf(stderr, "Failed to load \"%s\": %s\n", lib_path, error); + } + } + return librustdesk; +} + bool flutter_rustdesk_core_main() { - void* librustdesk = dlopen(RUSTDESK_LIB_PATH, RTLD_LAZY); + void* librustdesk = dlopen_bundled_lib(); + if (!librustdesk) { + librustdesk = dlopen(RUSTDESK_LIB_PATH, RTLD_LAZY); + } if (!librustdesk) { fprintf(stderr,"Failed to load \"librustdesk.so\"\n"); char* error; diff --git a/flutter/macos/Runner/Configs/AppInfo.xcconfig b/flutter/macos/Runner/Configs/AppInfo.xcconfig index eabc428e5ec..4c6b155d646 100644 --- a/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved. diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index c6f8aa1c20a..cba9ba5eab9 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -340,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: b47e8385e5a75d38319ad706a64b0ead3108b093 + resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.0" @@ -1589,7 +1589,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "85789bfe6e4cfaf4ecc00c52857467fdb7f26879" + resolved-ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc url: "https://github.com/rustdesk-org/window_manager" source: git version: "0.3.6" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 7bc77e735dd..b9f8e1ccbe1 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers -version: 1.4.8+66 +version: 1.4.9+67 environment: sdk: '^3.1.0' diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 2e1971753ab..5a1a76a77ef 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -122,4 +122,394 @@ void main() { ); }); }); + + group('shouldApplyTerminalInputModifiers', () { + test('accepts ordinary single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('a'), isTrue); + expect(shouldApplyTerminalInputModifiers(' '), isTrue); + expect(shouldApplyTerminalInputModifiers('/'), isTrue); + }); + + test('accepts supplementary-plane single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('😀'), isTrue); + }); + + test('rejects terminal control bytes and multi-character sequences', () { + for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) { + expect( + shouldApplyTerminalInputModifiers(input), + isFalse, + reason: '${input.codeUnits} must not consume a one-shot modifier', + ); + } + expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse); + }); + }); + + group('applyTerminalInputModifiers', () { + test('keeps decomposed graphemes intact under Ctrl', () { + const decomposedEAcute = 'e\u0301'; + + expect( + applyTerminalInputModifiers( + decomposedEAcute, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('keeps non-ASCII graphemes intact under Ctrl', () { + for (final input in ['é', '😀']) { + expect( + applyTerminalInputModifiers( + input, + ctrlLocked: true, + altLocked: false, + ), + input, + ); + } + }); + + test('maps Ctrl underscore to unit separator', () { + expect( + applyTerminalInputModifiers( + '_', + ctrlLocked: true, + altLocked: false, + ), + '\x1F', + ); + }); + + test('maps the complete Ctrl symbol range', () { + const mappings = { + '[': '\x1B', + r'\': '\x1C', + ']': '\x1D', + '^': '\x1E', + '_': '\x1F', + '/': '\x1F', + }; + + for (final entry in mappings.entries) { + expect( + applyTerminalInputModifiers( + entry.key, + ctrlLocked: true, + altLocked: false, + ), + entry.value, + reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}', + ); + } + }); + + test('applies Ctrl before Alt for combined modifiers', () { + expect( + applyTerminalInputModifiers( + 'b', + ctrlLocked: true, + altLocked: true, + ), + '\x1B\x02', + ); + }); + }); + + group('terminalPastePayload', () { + test('wraps paste text when bracketed paste mode is active', () { + expect( + terminalPastePayload('d', bracketedPasteMode: true), + '\x1B[200~d\x1B[201~', + ); + }); + + test('keeps a lone newline unchanged when bracketed paste is disabled', () { + expect( + terminalPastePayload('\n', bracketedPasteMode: false), + '\n', + ); + }); + }); + + group('prepareTerminalInputPayload', () { + test('normalizes a mobile keyboard Enter to carriage return', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + '\r', + ); + }); + + test('keeps Ctrl+J as line feed on mobile', () { + expect( + prepareTerminalInputPayload( + 'j', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + '\n', + ); + }); + + test('does not apply Alt to a terminal control byte', () { + expect( + prepareTerminalInputPayload( + '\x1B', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: true, + ), + '\x1B', + ); + }); + + test('keeps large keyboard payloads unchanged when modifiers are inactive', + () { + final payload = 'd' * (1024 * 1024); + + expect( + prepareTerminalInputPayload( + payload, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: false, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + payload, + ); + }); + + test('keeps decomposed graphemes intact with locked keyboard modifiers', + () { + const decomposedEAcute = 'e\u0301'; + + expect( + prepareTerminalInputPayload( + decomposedEAcute, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('preserves a lone pasted newline when modifiers are locked', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: true, + ), + '\n', + ); + }); + + test('wraps paste without applying locked modifiers', () { + expect( + prepareTerminalInputPayload( + 'd', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: true, + ctrlLocked: true, + altLocked: true, + ), + '\x1B[200~d\x1B[201~', + ); + }); + }); + + group('shouldHandleTerminalPasteShortcut', () { + test( + 'keeps default xterm paste behavior when virtual modifiers are inactive', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: false, + ), + isFalse, + ); + }); + + test('handles Ctrl+V and Meta+V when a virtual modifier lock is active', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: true, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('handles paste shortcut repeats while a virtual lock is active', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: true, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('ignores key-up and unmodified V events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + + test('ignores paste shortcuts with extra modifiers', () { + for (final state in [ + (control: true, meta: false, alt: true, shift: false), + (control: true, meta: false, alt: false, shift: true), + (control: false, meta: true, alt: false, shift: true), + (control: true, meta: true, alt: false, shift: false), + ]) { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: state.control, + metaPressed: state.meta, + altPressed: state.alt, + shiftPressed: state.shift, + modifierLockActive: true, + ), + isFalse, + ); + } + }); + + test('ignores non-V key events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyC, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + }); + + group('shouldClearTerminalModifiersWhenRow3Collapses', () { + test('clears visible modifier state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: true, + altLocked: false, + ), + isTrue, + ); + }); + + test('does not clear modifiers when row expands', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: false, + willExpand: true, + ctrlLocked: true, + altLocked: true, + ), + isFalse, + ); + }); + + test('clears Alt state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: false, + altLocked: true, + ), + isTrue, + ); + }); + }); } diff --git a/flutter/test/terminal_keyboard_utils_test.dart b/flutter/test/terminal_keyboard_utils_test.dart new file mode 100644 index 00000000000..c93a4241327 --- /dev/null +++ b/flutter/test/terminal_keyboard_utils_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('mobile terminal keyboard layout', () { + test('keeps the latest key order from the reviewed PR layout', () { + expect( + terminalKeyboardRow1Keys, + ['Esc', '/', '|', 'Home', '↑', 'End', r'\'], + ); + expect( + terminalKeyboardRow2Keys, + ['Tab', 'Ctrl+C', '~', '←', '↓', '→'], + ); + expect( + terminalKeyboardRow3Keys, + ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'], + ); + }); + + test('keeps two trailing Row3 placeholders for row alignment', () { + expect(terminalKeyboardRow3TrailingPlaceholderCount, 2); + }); + + test('keeps every expanded row aligned at 348dp', () { + final rowWidths = [ + terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length), + terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1), + terminalKeyboardRowWidth( + terminalKeyboardRow3Keys.length + + terminalKeyboardRow3TrailingPlaceholderCount, + ), + ]; + + expect(terminalKeyboardKeyWidth, 48); + expect(terminalKeyboardKeySpacing, 2); + expect(rowWidths, everyElement(348)); + }); + }); +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart new file mode 100644 index 00000000000..5581886b709 --- /dev/null +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +class _FakeFFI implements FFI { + @override + String id = 'test-peer'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('ignores paste that completes after the terminal model is disposed', + () async { + final model = TerminalModel(_FakeFFI()); + final delayedClipboardText = Completer(); + + // This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and + // model are disposed before the asynchronous read supplies its text. + final paste = delayedClipboardText.future.then(model.pasteText); + model.dispose(); + delayedClipboardText.complete('late clipboard text'); + await paste; + + expect(model.debugBufferedInputCount, 0); + }); + + test('ignores terminal text input after the terminal model is disposed', () { + final model = TerminalModel(_FakeFFI()); + var checkedCtrlLock = false; + var clearedCtrlLock = false; + + model.isCtrlLocked = () { + checkedCtrlLock = true; + return true; + }; + model.clearCtrlLock = () { + clearedCtrlLock = true; + }; + + model.dispose(); + model.terminal.textInput('d'); + + expect(checkedCtrlLock, isFalse); + expect(clearedCtrlLock, isFalse); + expect(model.debugBufferedInputCount, 0); + }); + + test('builds its terminal with the wheel button fix', () { + final model = TerminalModel(_FakeFFI()); + addTearDown(model.dispose); + + final captured = []; + model.terminal.onOutput = captured.add; + model.terminal.write('\x1b[?1000h\x1b[?1006h'); + model.terminal.mouseInput( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(10, 5), + ); + + expect(captured.single, '\x1b[<64;11;6M'); + }); +} diff --git a/flutter/test/terminal_mouse_handler_test.dart b/flutter/test/terminal_mouse_handler_test.dart new file mode 100644 index 00000000000..3fae7f71d7b --- /dev/null +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + late Terminal terminal; + late List output; + + setUp(() { + output = []; + terminal = Terminal(mouseHandler: const WheelButtonFixMouseHandler()) + ..onOutput = output.add; + }); + + String? report( + TerminalMouseButton button, [ + TerminalMouseButtonState state = TerminalMouseButtonState.down, + CellOffset position = const CellOffset(10, 5), + ]) { + output.clear(); + terminal.mouseInput(button, state, position); + return output.isEmpty ? null : output.single; + } + + test('reports SGR wheel buttons without the Shift modifier bit', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect(report(TerminalMouseButton.wheelUp), '\x1b[<64;11;6M'); + expect(report(TerminalMouseButton.wheelDown), '\x1b[<65;11;6M'); + expect(report(TerminalMouseButton.wheelLeft), '\x1b[<66;11;6M'); + expect(report(TerminalMouseButton.wheelRight), '\x1b[<67;11;6M'); + }); + + test('reports normal-encoding wheel buttons in the 64..67 range', () { + terminal.write('\x1b[?1000h'); + + expect( + report(TerminalMouseButton.wheelUp), + '\x1b[M${String.fromCharCode(32 + 64)}' + '${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}', + ); + expect( + report(TerminalMouseButton.wheelDown), + '\x1b[M${String.fromCharCode(32 + 65)}' + '${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}', + ); + }); + + test('reports utf-encoding wheel buttons beyond the normal-mode range', () { + terminal.write('\x1b[?1000h\x1b[?1005h'); + + expect( + report( + TerminalMouseButton.wheelDown, + TerminalMouseButtonState.down, + const CellOffset(400, 300), + ), + '\x1b[M${String.fromCharCode(32 + 65)}' + '${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}', + ); + }); + + test('reports urxvt-encoding wheel buttons shifted by 32', () { + terminal.write('\x1b[?1000h\x1b[?1015h'); + + expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M'); + expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M'); + }); + + test('sends a null byte for coordinates past the encoding limit', () { + terminal.write('\x1b[?1000h'); + + expect( + report( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(300, 300), + ), + '\x1b[M${String.fromCharCode(32 + 64)}\x00\x00', + ); + }); + + test('leaves non-wheel buttons to the upstream handler', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M'); + expect(report(TerminalMouseButton.middle), '\x1b[<1;11;6M'); + expect( + report(TerminalMouseButton.right, TerminalMouseButtonState.up), + '\x1b[<2;11;6m', + ); + }); + + test('stays silent when the peer has not enabled mouse reporting', () { + expect(report(TerminalMouseButton.wheelDown), isNull); + expect(report(TerminalMouseButton.left), isNull); + }); + + test('stays silent for the wheel in click-only mode', () { + terminal.write('\x1b[?9h\x1b[?1006h'); + + expect(report(TerminalMouseButton.wheelDown), isNull); + expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M'); + }); + + test('does not report wheel button releases', () { + terminal.write('\x1b[?1000h\x1b[?1006h'); + + expect( + report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up), + isNull, + ); + }); +} diff --git a/flutter/windows/runner/Runner.rc b/flutter/windows/runner/Runner.rc index ab1b7e06fed..88d18544eb8 100644 --- a/flutter/windows/runner/Runner.rc +++ b/flutter/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "Purslane Ltd" "\0" + VALUE "CompanyName", "Purslane Tech Pte. Ltd." "\0" VALUE "FileDescription", "RustDesk Remote Desktop" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "rustdesk" "\0" - VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." "\0" VALUE "OriginalFilename", "rustdesk.exe" "\0" VALUE "ProductName", "RustDesk" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/flutter/windows/runner/flutter_window.cpp b/flutter/windows/runner/flutter_window.cpp index 903fb2fa0dd..501436d71ab 100644 --- a/flutter/windows/runner/flutter_window.cpp +++ b/flutter/windows/runner/flutter_window.cpp @@ -19,6 +19,66 @@ #include "win32_desktop.h" +namespace { + +// If the window is resized between the creation of the Flutter surface and the +// present of the first frame - which is what the PowerToys FancyZones option +// "Move newly created windows to their last known zone" does - the embedder's +// resize synchronization enters kResizeStarted and from then on only presents +// frames that match the new size. A frame already generated for the old size +// is rejected, nothing schedules a matching one, and the window stays white +// until a real resize re-enters OnWindowSizeChanged, which resets the resize +// target and resends the window metrics. That is why minimize/restore heals +// it; ForceChildRefresh() below does the same programmatically. +// https://github.com/rustdesk/rustdesk/issues/6756 +// https://github.com/flutter/flutter/issues/159630 +// +// The timer below drives that recovery. Two subtleties, verified against the +// embedder sources (identical in 3.24.5 and 3.44.0): +// - FlutterViewController::ForceRedraw() only schedules a frame when NO resize +// is pending (resize_status_ == kDone), so it cannot heal the wedge above. +// It is kept as a cheap first kick for the case it was designed for: a +// window created hidden and shown later, with nothing scheduling a frame. +// - The SetNextFrameCallback used to detect the first frame fires when a frame +// is GENERATED (raster thread), even if the resize gate then rejects its +// present. So it must not be the only stop condition: one final +// ForceChildRefresh() is issued to guarantee a present at the current size. +// Note this premise is not load-bearing, and the redundancy is deliberate: +// if the callback in fact only fired on a successful present, then +// first_frame_rendered_ would stay false and the timer below would keep +// nudging until it healed. +// This also relies on HandleTopLevelWindowProc not consuming WM_TIMER (no +// plugin registers a delegate for it today). +constexpr UINT_PTR kForceRedrawTimerId = 0xFB15; +constexpr UINT kForceRedrawIntervalMs = 200; +// Give up eventually (with a log), so a genuinely stuck engine doesn't keep a +// timer alive forever. 25 * 200ms covers slow starts comfortably. +constexpr UINT kForceRedrawMaxTries = 25; +// The first ticks use the cheap ForceRedraw(); later ticks use +// ForceChildRefresh(), which may block the platform thread for up to 2x100ms +// per call (each nudge re-enters the 100ms resize wait). +constexpr UINT kForceRedrawCheapTries = 2; + +// Re-enters the embedder's OnWindowSizeChanged by nudging the Flutter child +// window by 1px and back: this resets the resize target and resends the window +// metrics. Same as BaseFlutterWindow::ForceChildRefresh() on the +// rustdesk_desktop_multi_window side. +void ForceChildRefresh(HWND child) { + if (!child) { + return; + } + RECT rect; + GetWindowRect(child, &rect); + LONG width = rect.right - rect.left; + LONG height = rect.bottom - rect.top; + SetWindowPos(child, nullptr, 0, 0, width + 1, height, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOMOVE | SWP_FRAMECHANGED); + SetWindowPos(child, nullptr, 0, 0, width, height, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOMOVE | SWP_FRAMECHANGED); +} + +} // namespace + FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} @@ -92,10 +152,17 @@ bool FlutterWindow::OnCreate() { registry->GetRegistrarForPlugin("FlutterGpuTextureRendererPluginCApi")); }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + // See the comment on kForceRedrawTimerId above. + flutter_controller_->engine()->SetNextFrameCallback( + [this]() { first_frame_rendered_ = true; }); + SetTimer(GetHandle(), kForceRedrawTimerId, kForceRedrawIntervalMs, nullptr); + return true; } void FlutterWindow::OnDestroy() { + KillTimer(GetHandle(), kForceRedrawTimerId); if (flutter_controller_) { flutter_controller_ = nullptr; } @@ -121,6 +188,48 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; + case WM_TIMER: + if (wparam == kForceRedrawTimerId) { + if (!flutter_controller_) { + KillTimer(hwnd, kForceRedrawTimerId); + } else if (first_frame_rendered_) { + // A frame was generated, which does not mean it was presented: if a + // resize was pending, the gate rejected it (see the comment on + // kForceRedrawTimerId). One child refresh guarantees a present at the + // current size. Unconditional because gating it bought nothing: the + // WM_SIZE that CreateWindow() sends already arrives before the first + // frame, so the flag this used to check was always set by the time we + // got here. Doing it unconditionally is safe either way - at worst it + // is one extra nudge, and it is cheap once the engine is running. + ForceChildRefresh(flutter_controller_->view()->GetNativeWindow()); + KillTimer(hwnd, kForceRedrawTimerId); + } else if (++force_redraw_tries_ > kForceRedrawMaxTries) { + // Not std::cerr: the runner only attaches a console when started from + // one or under a debugger (see main.cpp), and this fires on end-user + // machines. OutputDebugString is readable with DebugView there. + OutputDebugStringA( + "rustdesk: Flutter window did not render its first frame, " + "giving up.\n"); + KillTimer(hwnd, kForceRedrawTimerId); + } else if (force_redraw_tries_ <= kForceRedrawCheapTries) { + flutter_controller_->ForceRedraw(); + } else { + ForceChildRefresh(flutter_controller_->view()->GetNativeWindow()); + } + return 0; + } + break; + case WM_SHOWWINDOW: + // A window created hidden (e.g. the connection manager) may be shown + // long after the creation-time force-redraw timer has given up, and + // FancyZones moves windows exactly when they are shown. Re-arm the + // protection if the first frame still hasn't been rendered by now (see + // kForceRedrawTimerId). + if (wparam == TRUE && !first_frame_rendered_ && flutter_controller_) { + force_redraw_tries_ = 0; + SetTimer(hwnd, kForceRedrawTimerId, kForceRedrawIntervalMs, nullptr); + } + break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); diff --git a/flutter/windows/runner/flutter_window.h b/flutter/windows/runner/flutter_window.h index 6da0652f05f..e7aad49e2ed 100644 --- a/flutter/windows/runner/flutter_window.h +++ b/flutter/windows/runner/flutter_window.h @@ -28,6 +28,14 @@ class FlutterWindow : public Win32Window { // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; + + // Whether the engine has generated its first frame. Note that a generated + // frame is not necessarily presented: the resize synchronization may reject + // it (see kForceRedrawTimerId in the .cpp file). + bool first_frame_rendered_ = false; + + // Number of force-redraw attempts made so far. + UINT force_redraw_tries_ = 0; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/flutter/windows/runner/main.cpp b/flutter/windows/runner/main.cpp index cd9f386b107..ea9152ec7b4 100644 --- a/flutter/windows/runner/main.cpp +++ b/flutter/windows/runner/main.cpp @@ -14,6 +14,7 @@ typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*); typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int); typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int); +typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)(); /// Note: `--server`, `--service` are already handled in [core_main.rs]. const std::vector parameters_white_list = {"--install", "--cm"}; @@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, } std::vector rust_args(c_args, c_args + args_len); free_c_args(c_args, args_len); + FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation = + (FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation"); + bool is_disable_installation = + rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0; + const auto installParam = std::string("--install"); + // Flutter reads the original process command line, not only rust_args, so + // remove the `--install` injected by the portable wrapper here as well. This + // also lets `no-install.exe` continue as a portable app when installation is + // disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890 + if (is_disable_installation) { + command_line_arguments.erase( + std::remove(command_line_arguments.begin(), + command_line_arguments.end(), + installParam), + command_line_arguments.end()); + } std::wstring app_name = L"RustDesk"; FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name"); @@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, is_cm_page = true; } bool is_install_page = false; - auto installParam = std::string("--install"); if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) { is_install_page = true; } diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f3137..7e15791e9a2 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true} percent-encoding = {version ="2.3", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} -fuser = {version = "0.15", default-features = false, optional = true} +fuser = {git="https://github.com/rustdesk-org/fuser", branch = "refact/tag-0.16.0-cargo-1.75.0", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} diff --git a/libs/clipboard/README.md b/libs/clipboard/README.md index ec08cbf04e3..5bac6558b55 100644 --- a/libs/clipboard/README.md +++ b/libs/clipboard/README.md @@ -1,7 +1,7 @@ # clipboard Copy files and text through network. -Main lowlevel logic from [FreeRDP](https://github.com/FreeRDP/FreeRDP). +Main low-level logic from [FreeRDP](https://github.com/FreeRDP/FreeRDP). To enjoy file copy and paste feature on Linux/OSX, please build with `unix-file-copy-paste` feature. @@ -151,7 +151,7 @@ the FUSE server will figure out the file system tree and rearrange its content. - you may notice the mountpoint is still occupied after the application quits. That's because the FUSE server was not mounted with `AUTO_UNMOUNT`. - - It's hard to implement gressful shutdown for a multi-processed program + - It's hard to implement graceful shutdown for a multi-processed program - `AUTO_UNMOUNT` was not enabled by default and requires enable `user_allow_other` in configure. Letting users edit such global configuration to use this feature might not be a good idea. diff --git a/libs/clipboard/src/platform/unix/filetype.rs b/libs/clipboard/src/platform/unix/filetype.rs index 8436ba05ee2..ca5cc0a5ed9 100644 --- a/libs/clipboard/src/platform/unix/filetype.rs +++ b/libs/clipboard/src/platform/unix/filetype.rs @@ -1,4 +1,7 @@ -use super::{FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, LDAP_EPOCH_DELTA}; +use super::{ + FILE_NAME_FIELD_SIZE, FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, + LDAP_EPOCH_DELTA, +}; use crate::CliprdrError; use hbb_common::{ bytes::{Buf, Bytes}, @@ -47,6 +50,23 @@ pub struct FileDescription { pub perm: u16, } +pub(super) fn validate_file_name(name: &str) -> Result<(), CliprdrError> { + if matches!(name.as_bytes(), [letter, b':', b'/', ..] if letter.is_ascii_alphabetic()) + || name + .split('/') + .any(|component| component.is_empty() || component == ".") + { + return Err(CliprdrError::InvalidRequest { + description: "clipboard file name is not a normalized relative path".to_string(), + }); + } + hbb_common::fs::validate_file_name_no_traversal(name).map_err(|error| { + CliprdrError::InvalidRequest { + description: error.to_string(), + } + }) +} + impl FileDescription { fn parse_file_descriptor( bytes: &mut Bytes, @@ -68,13 +88,21 @@ impl FileDescription { // file size let file_size_high = bytes.get_u32_le(); let file_size_low = bytes.get_u32_le(); - // utf16 file name, double \0 terminated, in 520 bytes block + // NUL-terminated UTF-16 file name in a fixed-size field. // read with another pointer, and advance the main pointer let block = bytes.clone(); - bytes.advance(520); + bytes.advance(FILE_NAME_FIELD_SIZE); - let block = &block[..520]; - let wstr = WStr::from_utf16le(block).map_err(|e| { + let block = &block[..FILE_NAME_FIELD_SIZE]; + let utf16_unit_size = std::mem::size_of::(); + let name_end = block + .chunks_exact(utf16_unit_size) + .position(|unit| unit == [0_u8, 0_u8]) + .ok_or_else(|| CliprdrError::InvalidRequest { + description: "clipboard file name is not null-terminated".to_string(), + })? + * utf16_unit_size; + let wstr = WStr::from_utf16le(&block[..name_end]).map_err(|e| { log::error!("cannot convert file descriptor path: {:?}", e); CliprdrError::ConversionFailure })?; @@ -136,7 +164,8 @@ impl FileDescription { }; let name = wstr.to_utf8().replace('\\', "/"); - let name = PathBuf::from(name.trim_end_matches('\0')); + validate_file_name(&name)?; + let name = PathBuf::from(name); let desc = FileDescription { conn_id, @@ -186,3 +215,81 @@ impl FileDescription { Ok(files) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + const PDU_HEADER_SIZE: usize = size_of::(); + const DESCRIPTOR_SIZE: usize = 592; + const ATTRIBUTES_OFFSET: usize = PDU_HEADER_SIZE + 36; + const NAME_OFFSET: usize = PDU_HEADER_SIZE + 72; + const FILE_NAME_CODE_UNITS: usize = 260; + const INVALID_UTF16_UNIT: u16 = 0xdc00; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; + + fn descriptor_pdu(name: &str) -> Vec { + let mut pdu = vec![0_u8; PDU_HEADER_SIZE + DESCRIPTOR_SIZE]; + pdu[..PDU_HEADER_SIZE].copy_from_slice(&1_u32.to_le_bytes()); + pdu[PDU_HEADER_SIZE..PDU_HEADER_SIZE + size_of::()] + .copy_from_slice(&FLAGS_FD_ATTRIBUTES.to_le_bytes()); + pdu[ATTRIBUTES_OFFSET..ATTRIBUTES_OFFSET + size_of::()] + .copy_from_slice(&FILE_ATTRIBUTE_NORMAL.to_le_bytes()); + for (index, unit) in name.encode_utf16().enumerate() { + let offset = NAME_OFFSET + index * size_of::(); + pdu[offset..offset + size_of::()].copy_from_slice(&unit.to_le_bytes()); + } + pdu + } + + fn parse_name(name: &str) -> Result, CliprdrError> { + FileDescription::parse_file_descriptors(descriptor_pdu(name), 0) + } + + #[test] + fn rejects_unsafe_file_names() { + for name in [ + "../payload", + "/tmp/payload", + "C:\\payload", + "folder//payload", + "folder/./payload", + "folder/", + "", + ".", + ] { + assert!(matches!( + parse_name(name), + Err(CliprdrError::InvalidRequest { .. }) + )); + } + } + + #[test] + fn accepts_nested_relative_file_name() { + let files = parse_name("folder\\nested\\file.txt").unwrap(); + assert_eq!(files[0].name, PathBuf::from("folder/nested/file.txt")); + } + + #[test] + fn ignores_data_after_null_terminator() { + let name = "file.txt"; + let mut pdu = descriptor_pdu(name); + let padding_offset = NAME_OFFSET + (name.encode_utf16().count() + 1) * size_of::(); + pdu[padding_offset..padding_offset + size_of::()] + .copy_from_slice(&INVALID_UTF16_UNIT.to_le_bytes()); + + let files = FileDescription::parse_file_descriptors(pdu, 0).unwrap(); + assert_eq!(files[0].name, PathBuf::from("file.txt")); + } + + #[test] + fn rejects_non_terminated_file_name() { + let name = "a".repeat(FILE_NAME_CODE_UNITS); + assert!(matches!( + parse_name(&name), + Err(CliprdrError::InvalidRequest { .. }) + )); + } +} diff --git a/libs/clipboard/src/platform/unix/local_file.rs b/libs/clipboard/src/platform/unix/local_file.rs index 50c67b68f3b..36728e1ce94 100644 --- a/libs/clipboard/src/platform/unix/local_file.rs +++ b/libs/clipboard/src/platform/unix/local_file.rs @@ -1,4 +1,7 @@ -use super::{BLOCK_SIZE, LDAP_EPOCH_DELTA}; +use super::{ + filetype::validate_file_name, BLOCK_SIZE, FILE_NAME_CODE_UNITS, FILE_NAME_FIELD_SIZE, + LDAP_EPOCH_DELTA, +}; use crate::{ platform::unix::{ FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_PROGRESSUI, FLAGS_FD_SIZE, @@ -21,15 +24,19 @@ use std::{ }; use utf16string::WString; +const FILE_DESCRIPTOR_SIZE: usize = 592; +const MAX_FILE_NAME_CODE_UNITS: usize = FILE_NAME_CODE_UNITS - 1; +const UTF16_CODE_UNIT_SIZE: usize = std::mem::size_of::(); + #[derive(Debug)] pub(super) struct LocalFile { - pub relative_root: PathBuf, pub path: PathBuf, pub handle: Option>, pub offset: AtomicU64, pub name: String, + descriptor_name: String, pub size: u64, pub last_write_time: SystemTime, pub is_dir: bool, @@ -42,7 +49,38 @@ pub(super) struct LocalFile { } impl LocalFile { + fn descriptor_name_too_long_error() -> CliprdrError { + CliprdrError::InvalidRequest { + description: format!( + "clipboard file name exceeds {MAX_FILE_NAME_CODE_UNITS} UTF-16 code units" + ), + } + } + + fn validated_descriptor_name( + relative_root: &Path, + path: &Path, + ) -> Result { + let descriptor_path = + path.strip_prefix(relative_root) + .map_err(|_| CliprdrError::InvalidRequest { + description: "clipboard file path is outside its relative root".to_string(), + })?; + if descriptor_path.is_absolute() { + return Err(CliprdrError::InvalidRequest { + description: "clipboard file path must be relative".to_string(), + }); + } + let descriptor_name = descriptor_path.to_string_lossy().into_owned(); + validate_file_name(&descriptor_name)?; + if descriptor_name.encode_utf16().count() > MAX_FILE_NAME_CODE_UNITS { + return Err(Self::descriptor_name_too_long_error()); + } + Ok(descriptor_name) + } + pub fn try_open(relative_root: &Path, path: &Path) -> Result { + let descriptor_name = Self::validated_descriptor_name(relative_root, path)?; let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError { path: path.to_string_lossy().to_string(), err: e, @@ -70,11 +108,11 @@ impl LocalFile { Ok(Self { name, - relative_root: relative_root.to_path_buf(), path: path.to_path_buf(), handle, offset, size, + descriptor_name, last_write_time, is_dir, read_only, @@ -85,17 +123,37 @@ impl LocalFile { normal, }) } - pub fn as_bin(&self) -> Vec { - let mut buf = BytesMut::with_capacity(592); + fn put_descriptor_name(&self, buf: &mut BytesMut) -> Result<(), CliprdrError> { + validate_file_name(&self.descriptor_name)?; + let wstr: WString = WString::from(&self.descriptor_name); + let name = wstr.as_bytes(); + let Some(name_field_size) = name.len().checked_add(UTF16_CODE_UNIT_SIZE) else { + return Err(Self::descriptor_name_too_long_error()); + }; + if name_field_size > FILE_NAME_FIELD_SIZE { + return Err(Self::descriptor_name_too_long_error()); + } + log::trace!( + "put file to list: name_len {}, name {}", + name.len(), + &self.name + ); + buf.put(name); + buf.put_u16_le(0); + buf.put_bytes(0, FILE_NAME_FIELD_SIZE - name_field_size); + Ok(()) + } + + pub fn as_bin(&self) -> Result, CliprdrError> { + let mut buf = BytesMut::with_capacity(FILE_DESCRIPTOR_SIZE); let read_only_flag = if self.read_only { 0x1 } else { 0 }; let hidden_flag = if self.hidden { 0x2 } else { 0 }; let system_flag = if self.system { 0x4 } else { 0 }; let directory_flag = if self.is_dir { 0x10 } else { 0 }; let archive_flag = if self.archive { 0x20 } else { 0 }; let normal_flag = if self.normal { 0x80 } else { 0 }; - - let file_attributes: u32 = read_only_flag + let file_attributes = read_only_flag | hidden_flag | system_flag | directory_flag @@ -112,23 +170,6 @@ impl LocalFile { let size_high = (self.size >> 32) as u32; let size_low = (self.size & (u32::MAX as u64)) as u32; - - let path = self - .path - .strip_prefix(&self.relative_root) - .unwrap_or(&self.path) - .to_string_lossy() - .into_owned(); - - let wstr: WString = WString::from(&path); - let name = wstr.as_bytes(); - - log::trace!( - "put file to list: name_len {}, name {}", - name.len(), - &self.name - ); - let flags = FLAGS_FD_SIZE | FLAGS_FD_LAST_WRITE | FLAGS_FD_ATTRIBUTES @@ -157,12 +198,10 @@ impl LocalFile { buf.put_u32_le(size_high); // file size (low) buf.put_u32_le(size_low); - // put name and padding to 520 bytes - let name_len = name.len(); - buf.put(name); - buf.put(&vec![0u8; 520 - name_len][..]); + // Put the null-terminated name and padding into the fixed-size field. + self.put_descriptor_name(&mut buf)?; - buf.to_vec() + Ok(buf.to_vec()) } #[inline] @@ -263,20 +302,18 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C } let mut file_list = Vec::new(); - let mut visited = HashSet::new(); - let relative_root = paths - .first() - .ok_or(CliprdrError::InvalidRequest { + if paths.is_empty() { + return Err(CliprdrError::InvalidRequest { description: "empty file list".to_string(), - })? - .parent() - .ok_or(CliprdrError::InvalidRequest { - description: "empty parent".to_string(), - })? - .to_path_buf(); + }); + } for path in paths { - constr_file_lst(&relative_root, path, &mut file_list, &mut visited)?; + let relative_root = path.parent().ok_or(CliprdrError::InvalidRequest { + description: "empty parent".to_string(), + })?; + let mut visited = HashSet::new(); + constr_file_lst(relative_root, path, &mut file_list, &mut visited)?; } Ok(file_list) } @@ -284,7 +321,7 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C #[cfg(test)] mod file_list_test { use std::{ - path::PathBuf, + path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, }; @@ -292,7 +329,7 @@ mod file_list_test { use crate::{platform::unix::filetype::FileDescription, CliprdrError}; - use super::LocalFile; + use super::{LocalFile, FILE_DESCRIPTOR_SIZE, MAX_FILE_NAME_CODE_UNITS, UTF16_CODE_UNIT_SIZE}; #[inline] fn generate_tree(prefix: &str) -> Vec { @@ -304,10 +341,10 @@ mod file_list_test { #[inline] fn generate_file(path: &str, name: &str, is_dir: bool) -> LocalFile { LocalFile { - relative_root: PathBuf::from("."), path: PathBuf::from(path), handle: None, name: name.to_string(), + descriptor_name: path.to_string(), size: 0, offset: AtomicU64::new(0), last_write_time: std::time::SystemTime::UNIX_EPOCH, @@ -352,29 +389,22 @@ mod file_list_test { let mut pdu = BytesMut::with_capacity(4 + 592 * tree.len()); pdu.put_u32_le(tree.len() as u32); for file in tree { - pdu.put(file.as_bin().as_slice()); + pdu.put(file.as_bin()?.as_slice()); } let parsed = FileDescription::parse_file_descriptors(pdu.to_vec(), 0)?; assert_eq!(parsed.len(), 4); - if !prefix.is_empty() { - assert_eq!(parsed[0].name.to_str().unwrap(), format!("{}", prefix)); - assert_eq!( - parsed[1].name.to_str().unwrap(), - format!("{}/a.txt", prefix) - ); - assert_eq!(parsed[2].name.to_str().unwrap(), format!("{}/b", prefix)); - assert_eq!( - parsed[3].name.to_str().unwrap(), - format!("{}/b/c.txt", prefix) - ); - } else { - assert_eq!(parsed[0].name.to_str().unwrap(), "."); - assert_eq!(parsed[1].name.to_str().unwrap(), "a.txt"); - assert_eq!(parsed[2].name.to_str().unwrap(), "b"); - assert_eq!(parsed[3].name.to_str().unwrap(), "b/c.txt"); - } + assert_eq!(parsed[0].name.to_str().unwrap(), format!("{}", prefix)); + assert_eq!( + parsed[1].name.to_str().unwrap(), + format!("{}/a.txt", prefix) + ); + assert_eq!(parsed[2].name.to_str().unwrap(), format!("{}/b", prefix)); + assert_eq!( + parsed[3].name.to_str().unwrap(), + format!("{}/b/c.txt", prefix) + ); assert!(parsed[0].perm & 0o777 == 0o754); assert!(parsed[1].perm & 0o777 == 0o754); @@ -386,10 +416,52 @@ mod file_list_test { #[test] fn test_parse_file_descriptors() -> Result<(), CliprdrError> { - as_bin_parse_test("")?; - as_bin_parse_test("/")?; as_bin_parse_test("test")?; - as_bin_parse_test("/test")?; + as_bin_parse_test("test/nested")?; + Ok(()) + } + + #[test] + fn rejects_file_outside_relative_root() { + let result = LocalFile::try_open(Path::new("/relative/root"), Path::new("/other/file")); + assert!(matches!(result, Err(CliprdrError::InvalidRequest { .. }))); + + let result = LocalFile::try_open( + Path::new("relative/root"), + Path::new("relative/root/../outside"), + ); + assert!(matches!(result, Err(CliprdrError::InvalidRequest { .. }))); + + let mut file = generate_tree("root").remove(0); + file.descriptor_name = "../outside".to_string(); + assert!(matches!( + file.as_bin(), + Err(CliprdrError::InvalidRequest { .. }) + )); + } + + #[test] + fn validates_utf16_descriptor_name_length() -> Result<(), CliprdrError> { + let validate = |name: &str| { + let path = Path::new("root").join(name); + LocalFile::validated_descriptor_name(Path::new("root"), &path) + }; + let valid_name = validate(&"a".repeat(MAX_FILE_NAME_CODE_UNITS))?; + let oversized_name = "a".repeat(MAX_FILE_NAME_CODE_UNITS + 1); + let invalid_name = validate(&oversized_name); + let mut valid_file = generate_tree("").remove(0); + valid_file.descriptor_name = valid_name; + let valid_descriptor = valid_file.as_bin()?; + valid_file.descriptor_name = oversized_name; + let invalid_descriptor = valid_file.as_bin(); + + assert_eq!(valid_descriptor.len(), FILE_DESCRIPTOR_SIZE); + assert!(valid_descriptor.ends_with(&[0_u8; UTF16_CODE_UNIT_SIZE])); + assert!(invalid_name.is_err()); + assert!(matches!( + invalid_descriptor, + Err(CliprdrError::InvalidRequest { .. }) + )); Ok(()) } diff --git a/libs/clipboard/src/platform/unix/macos/paste_task.rs b/libs/clipboard/src/platform/unix/macos/paste_task.rs index 33a11ed6c6e..5885ea655af 100644 --- a/libs/clipboard/src/platform/unix/macos/paste_task.rs +++ b/libs/clipboard/src/platform/unix/macos/paste_task.rs @@ -2,10 +2,10 @@ use crate::{ platform::unix::{FileDescription, FileType, BLOCK_SIZE}, send_data, ClipboardFile, CliprdrError, ProgressPercent, }; -use hbb_common::{allow_err, log, tokio::time::Instant}; +use hbb_common::{allow_err, fs::join_validated_path, log, tokio::time::Instant}; use std::{ cmp::min, - fs::{File, FileTimes}, + fs::{File, FileTimes, OpenOptions}, io::{BufWriter, Write}, os::macos::fs::FileTimesExt, path::{Path, PathBuf}, @@ -27,6 +27,10 @@ const RECEIVE_WAIT_TIMEOUT: Duration = Duration::from_millis(5_000); const TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED: u64 = 443779200; const ATTR_PROGRESS_FRACTION_COMPLETED: &str = "com.apple.progress.fractionCompleted"; +fn create_new_file(path: impl AsRef) -> std::io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + pub struct FileContentsResponse { pub conn_id: i32, pub msg_flags: i32, @@ -117,7 +121,15 @@ impl PasteTask { target_dir, files, }; - task_handle.update_next(0).ok(); + // Path validation and creation are not atomic. Local filesystem changes can + // invalidate checked paths, and entries created before an error are not rolled back. + if let Err(error) = task_handle + .validate_paths() + .and_then(|_| task_handle.update_next(0)) + { + log::error!("Failed to initialize paste task: {}", &error); + task_handle.on_error(error); + } if task_handle.is_finished() { task_handle.on_finished(); } else { @@ -250,6 +262,13 @@ impl PasteTask { } impl PasteTaskHandle { + fn validate_paths(&self) -> Result<(), CliprdrError> { + for file in &self.files { + Self::join_file_path(&self.target_dir, &file.name)?; + } + Ok(()) + } + fn update_next(&mut self, size: u64) -> Result<(), CliprdrError> { if self.is_finished() { return Ok(()); @@ -259,7 +278,7 @@ impl PasteTaskHandle { let is_start = self.progress.list_index == -1; if is_start || (self.progress.offset + size) >= self.progress.download_file_size { if !is_start { - self.on_done(); + self.on_done()?; } for i in (self.progress.list_index + 1)..self.files.len() as i32 { let Some(file_desc) = self.files.get(i as usize) else { @@ -270,14 +289,12 @@ impl PasteTaskHandle { match file_desc.kind { FileType::File => { if file_desc.size == 0 { - if let Some(new_file_path) = - Self::get_new_filename(&self.target_dir, file_desc) - { - if let Ok(f) = std::fs::File::create(&new_file_path) { - f.set_len(0).ok(); - Self::set_file_metadata(&f, file_desc); - } - }; + let path = Self::join_file_path(&self.target_dir, &file_desc.name)?; + if let Some(path) = Self::get_new_filename(path, file_desc) { + let f = create_new_file(&path) + .map_err(|err| CliprdrError::FileError { path, err })?; + Self::set_file_metadata(&f, file_desc); + } } else { self.progress.list_index = i; self.progress.offset = 0; @@ -286,10 +303,11 @@ impl PasteTaskHandle { } } FileType::Directory => { - let path = self.target_dir.join(&file_desc.name); - if !path.exists() { - std::fs::create_dir_all(path).ok(); - } + let path = Self::join_file_path(&self.target_dir, &file_desc.name)?; + std::fs::create_dir_all(&path).map_err(|err| CliprdrError::FileError { + path: path.to_string_lossy().to_string(), + err, + })?; } FileType::Symlink => { // to-do: handle symlink @@ -362,9 +380,7 @@ impl PasteTaskHandle { }); }; - let original_file_path = self - .target_dir - .join(&file.name) + let original_file_path = Self::join_file_path(&self.target_dir, &file.name)? .to_string_lossy() .to_string(); let Some(download_file_path) = Self::get_first_filename( @@ -391,7 +407,7 @@ impl PasteTaskHandle { }); } } - match std::fs::File::create(&download_file_path) { + match create_new_file(&download_file_path) { Ok(handle) => { let writer = BufWriter::with_capacity(BLOCK_SIZE as usize * 2, handle); self.progress.download_file_index = self.progress.list_index; @@ -446,6 +462,15 @@ impl PasteTaskHandle { None } + fn join_file_path(target_dir: &PathBuf, name: &Path) -> Result { + let name = name.to_str().ok_or_else(|| CliprdrError::InvalidRequest { + description: "clipboard file name is not valid UTF-8".to_string(), + })?; + join_validated_path(target_dir, name).map_err(|error| CliprdrError::InvalidRequest { + description: error.to_string(), + }) + } + fn progress_percent(&self) -> ProgressPercent { let percent = self.progress.current_size as f64 / self.progress.total_size as f64; ProgressPercent { @@ -476,8 +501,12 @@ impl PasteTaskHandle { fn on_finished(&mut self) { if self.progress.error.is_some() { self.on_cancelled(); - } else { - self.on_done(); + return; + } + if let Err(error) = self.on_done() { + log::error!("Failed to finish paste task: {}", &error); + self.on_error(error); + return; } if self.progress.current_size != self.progress.total_size { self.progress.error = Some(CliprdrError::InvalidRequest { @@ -496,15 +525,16 @@ impl PasteTaskHandle { std::fs::remove_file(&self.progress.download_file_path).ok(); } - fn on_done(&mut self) { + fn on_done(&mut self) -> Result<(), CliprdrError> { self.update_progress_completed(Some(1.0)); Self::remove_progress_completed(&self.progress.download_file_path); let Some(file) = self.progress.file_handle.as_mut() else { - return; + return Ok(()); }; if self.progress.download_file_index == PasteTask::INVALID_FILE_INDEX { - return; + log::error!("Invalid download file index"); + return Ok(()); } if let Err(e) = file.flush() { @@ -518,26 +548,26 @@ impl PasteTaskHandle { "Failed to get file description: {}", self.progress.download_file_index ); - return; + return Ok(()); }; - let Some(rename_to_path) = Self::get_new_filename(&self.target_dir, file_desc) else { - return; + let path = Self::join_file_path(&self.target_dir, &file_desc.name)?; + let Some(rename_to_path) = Self::get_new_filename(path, file_desc) else { + return Ok(()); }; - match std::fs::rename(&self.progress.download_file_path, &rename_to_path) { - Ok(_) => Self::set_file_metadata2(&rename_to_path, file_desc), - Err(e) => { - log::error!("Failed to rename file: {:?}", e); + std::fs::rename(&self.progress.download_file_path, &rename_to_path).map_err(|err| { + CliprdrError::FileError { + path: rename_to_path.clone(), + err, } - } + })?; + Self::set_file_metadata2(&rename_to_path, file_desc); self.progress.download_file_path = "".to_owned(); self.progress.download_file_index = PasteTask::INVALID_FILE_INDEX; + Ok(()) } - fn get_new_filename(target_dir: &PathBuf, file_desc: &FileDescription) -> Option { - let mut rename_to_path = target_dir - .join(&file_desc.name) - .to_string_lossy() - .to_string(); + fn get_new_filename(path: PathBuf, file_desc: &FileDescription) -> Option { + let mut rename_to_path = path.to_string_lossy().to_string(); if Path::new(&rename_to_path).exists() { let Some(new_path) = Self::get_first_filename(rename_to_path.clone(), file_desc.kind) else { @@ -637,3 +667,122 @@ impl PasteTaskHandle { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + struct TestDirectory(PathBuf); + + impl Drop for TestDirectory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + fn test_directories() -> (TestDirectory, PathBuf, PathBuf) { + let temp = TestDirectory(std::env::temp_dir().join(uuid::Uuid::new_v4().to_string())); + std::fs::create_dir(&temp.0).unwrap(); + let target = temp.0.join("target"); + let outside = temp.0.join("outside"); + std::fs::create_dir(&target).unwrap(); + std::fs::create_dir(&outside).unwrap(); + (temp, target, outside) + } + + fn file_description(name: &str, kind: FileType, size: u64) -> FileDescription { + FileDescription { + conn_id: 0, + name: PathBuf::from(name), + kind, + atime: SystemTime::UNIX_EPOCH, + last_modified: SystemTime::UNIX_EPOCH, + last_metadata_changed: SystemTime::UNIX_EPOCH, + creation_time: SystemTime::UNIX_EPOCH, + size, + perm: 0, + } + } + + fn paste_task_handle(target_dir: PathBuf, files: Vec) -> PasteTaskHandle { + PasteTaskHandle { + progress: PasteTaskProgress { + list_index: -1, + offset: 0, + total_size: files.iter().map(|file| file.size).sum(), + current_size: 0, + last_sent_time: Instant::now(), + download_file_index: PasteTask::INVALID_FILE_INDEX, + download_file_size: 0, + download_file_path: String::new(), + download_file_current_size: 0, + file_handle: None, + error: None, + is_canceled: false, + }, + target_dir, + files, + } + } + + #[test] + fn validates_all_paths_before_creating_files() { + let (_temp, target, outside) = test_directories(); + symlink(&outside, target.join("link")).unwrap(); + + let files = vec![ + file_description("created.txt", FileType::File, 0), + file_description("link/escaped", FileType::Directory, 0), + ]; + let mut task = paste_task_handle(target.clone(), files); + + assert!(matches!( + task.validate_paths().and_then(|_| task.update_next(0)), + Err(CliprdrError::InvalidRequest { .. }) + )); + assert!(!target.join("created.txt").exists()); + assert!(!outside.join("escaped").exists()); + } + + #[test] + fn final_path_validation_failure_marks_task_failed_and_removes_download() { + let (_temp, target, outside) = test_directories(); + + let download_path = target.join("file.rddownload"); + let download_file = create_new_file(&download_path).unwrap(); + let files = vec![file_description("link/file.txt", FileType::File, 1)]; + let mut task = paste_task_handle(target.clone(), files); + task.progress.list_index = 1; + task.progress.current_size = 1; + task.progress.download_file_index = 0; + task.progress.download_file_size = 1; + task.progress.download_file_path = download_path.to_string_lossy().to_string(); + task.progress.download_file_current_size = 1; + task.progress.file_handle = Some(BufWriter::new(download_file)); + symlink(&outside, target.join("link")).unwrap(); + + task.on_finished(); + + assert!(matches!( + task.progress.error, + Some(CliprdrError::InvalidRequest { .. }) + )); + assert!(!download_path.exists()); + assert!(!outside.join("file.txt").exists()); + } + + #[test] + fn rejects_symlink_component_when_creating_directory() { + let (_temp, target, outside) = test_directories(); + symlink(&outside, target.join("link")).unwrap(); + + let directory = file_description("link/escaped", FileType::Directory, 0); + let mut task = paste_task_handle(target, vec![directory]); + assert!(matches!( + task.update_next(0), + Err(CliprdrError::InvalidRequest { .. }) + )); + assert!(!outside.join("escaped").exists()); + } +} diff --git a/libs/clipboard/src/platform/unix/mod.rs b/libs/clipboard/src/platform/unix/mod.rs index de5917f495b..b908f3e116f 100644 --- a/libs/clipboard/src/platform/unix/mod.rs +++ b/libs/clipboard/src/platform/unix/mod.rs @@ -34,6 +34,10 @@ pub const FILECONTENTS_FORMAT_NAME: &str = "FileContents"; /// block size for fuse, align to our asynchronic request size over FileContentsRequest. pub(crate) const BLOCK_SIZE: u32 = 4 * 1024 * 1024; +/// `FILEDESCRIPTORW::cFileName` capacity, including the trailing NUL code unit. +pub(super) const FILE_NAME_CODE_UNITS: usize = 260; +pub(super) const FILE_NAME_FIELD_SIZE: usize = FILE_NAME_CODE_UNITS * std::mem::size_of::(); + // begin of epoch used by microsoft // 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00 const LDAP_EPOCH_DELTA: u64 = 116444772610000000; diff --git a/libs/clipboard/src/platform/unix/serv_files.rs b/libs/clipboard/src/platform/unix/serv_files.rs index 6fe66517cdd..507de3ef331 100644 --- a/libs/clipboard/src/platform/unix/serv_files.rs +++ b/libs/clipboard/src/platform/unix/serv_files.rs @@ -93,13 +93,14 @@ impl ClipFiles { Ok(()) } - fn build_file_list_pdu(&mut self) { + fn build_file_list_pdu(&mut self) -> Result<(), CliprdrError> { let mut data = BytesMut::with_capacity(4 + 592 * self.file_list.len()); data.put_u32_le(self.file_list.len() as u32); for file in self.file_list.iter() { - data.put(file.as_bin().as_slice()); + data.put(file.as_bin()?.as_slice()); } - self.files_pdu = data.to_vec() + self.files_pdu = data.to_vec(); + Ok(()) } fn get_files_for_audit(&self, request: &FileContentsRequest) -> Option { @@ -301,7 +302,7 @@ pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> { return Ok(()); } files_lock.sync_files(files, current)?; - Ok(files_lock.build_file_list_pdu()) + files_lock.build_file_list_pdu() } pub fn get_file_list_pdu() -> Vec { diff --git a/libs/clipboard/src/platform/windows.rs b/libs/clipboard/src/platform/windows.rs index cdeb3e4b0dd..20c516d7789 100644 --- a/libs/clipboard/src/platform/windows.rs +++ b/libs/clipboard/src/platform/windows.rs @@ -521,6 +521,8 @@ extern "C" { pub(crate) fn init_cliprdr(context: *mut CliprdrClientContext) -> BOOL; pub(crate) fn uninit_cliprdr(context: *mut CliprdrClientContext) -> BOOL; pub(crate) fn empty_cliprdr(context: *mut CliprdrClientContext, connID: UINT32) -> BOOL; + #[cfg(test)] + fn wf_cliprdr_file_descriptor_name_valid(name: *const WCHAR) -> BOOL; } unsafe impl Send for CliprdrClientContext {} @@ -1325,3 +1327,77 @@ extern "C" fn client_file_contents_response( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::iter::once; + + const FILE_NAME_CODE_UNITS: usize = 260; + const FILE_NAME_CASES: &[(&str, bool)] = &[ + ("", false), + ("/absolute", false), + ("C:\\absolute", false), + ("dir\\..\\payload", false), + ("dir//payload", false), + ("file.", false), + ("file ", false), + (" report.txt", false), + (" NUL.txt", false), + ("dir\\ nested.txt", false), + ("CON", false), + ("nul.txt", false), + ("dir\\AUX.log", false), + ("PRN.tar.gz", false), + ("com1", false), + ("COM\u{00b9}.txt", false), + ("COM\u{00b2}.txt", false), + ("lpt9.log", false), + ("dir/LPT\u{00b3}", false), + ("CONIN$", false), + ("dir\\conout$", false), + ("CLOCK$", false), + ("badname", false), + ("bad:name", false), + ("bad\"name", false), + ("bad|name", false), + ("bad?name", false), + ("bad*name", false), + ("bad\u{0001}name", false), + ("dir\\bad\u{001f}name", false), + ("normal.txt", true), + (".gitignore", true), + ("dir\\nested file.txt", true), + ("dir/nested file.txt", true), + ("com10.txt", true), + ("auxiliary.log", true), + ("clock$.txt", true), + ("conin$.txt", true), + ]; + + fn file_descriptor_name_valid(name: &str) -> bool { + let wide_name: Vec<_> = name.encode_utf16().chain(once(0)).collect(); + unsafe { wf_cliprdr_file_descriptor_name_valid(wide_name.as_ptr()) == TRUE } + } + + #[test] + fn validates_file_descriptor_names() { + for &(name, expected) in FILE_NAME_CASES { + assert_eq!( + file_descriptor_name_valid(name), + expected, + "unexpected validity for {name:?}" + ); + } + } + + #[test] + fn rejects_non_terminated_file_descriptor_name() { + let wide_name = [WCHAR::from(b'a'); FILE_NAME_CODE_UNITS]; + assert_eq!( + unsafe { wf_cliprdr_file_descriptor_name_valid(wide_name.as_ptr()) }, + FALSE + ); + } +} diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index 5fd08deebd1..c32a2025928 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -26,7 +26,9 @@ #define COBJMACROS #include +#include #include +#include #include #include #include @@ -41,6 +43,24 @@ /* Maximum number of clipboard streams accepted from a remote peer (integer overflow / DoS guard) */ #define WF_CLIPRDR_MAX_STREAMS 16384 +/* Registered clipboard formats use IDs 0xC000 through 0xFFFF. + * https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformatw */ +#define WF_CLIPRDR_MAX_FORMATS 0x4000u +/* Registered format names are string atoms; cap the converted WCHAR name. + * https://learn.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables */ +#define WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS 255u +/* Bound the peer-provided UTF-8 scan separately from the converted Windows name. */ +#define WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES (WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS * 4u) +/* File clipboard redirection always advertises the descriptor and contents formats. */ +#define WF_CLIPRDR_FILE_FORMAT_COUNT 2u +#define WF_CLIPRDR_COM_LPT_PREFIX_LENGTH 3u +static const WCHAR WF_CLIPRDR_SUPERSCRIPT_DIGITS[] = L"\x00B9\x00B2\x00B3"; +static const WCHAR WF_CLIPRDR_INVALID_FILE_NAME_CHARS[] = L"<>:\"|?*"; + +BOOL wf_cliprdr_format_data_size_valid(SIZE_T size) +{ + return size <= UINT32_MAX; +} /* Validates the remote descriptor array size after cItems has been read safely. */ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count) @@ -61,6 +81,138 @@ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count) return size >= descriptors_size; } +static BOOL wf_cliprdr_file_name_equals(const WCHAR *component, SIZE_T length, + const WCHAR *expected) +{ + SIZE_T expected_length = wcslen(expected); + SIZE_T i; + + if (length != expected_length) + return FALSE; + for (i = 0; i < length; i++) + { + WCHAR value = component[i]; + if (value >= L'a' && value <= L'z') + value -= L'a' - L'A'; + if (value != expected[i]) + return FALSE; + } + return TRUE; +} + +/* Windows reserves COM/LPT followed by ASCII 1-9 or superscript 1, 2, and 3. + * https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file */ +static BOOL wf_cliprdr_file_name_numbered_device(const WCHAR *component, SIZE_T length) +{ + SIZE_T prefix_length = WF_CLIPRDR_COM_LPT_PREFIX_LENGTH; + return length == prefix_length + 1 && + (wf_cliprdr_file_name_equals(component, prefix_length, L"COM") || + wf_cliprdr_file_name_equals(component, prefix_length, L"LPT")) && + ((component[prefix_length] >= L'1' && component[prefix_length] <= L'9') || + wcschr(WF_CLIPRDR_SUPERSCRIPT_DIGITS, component[prefix_length]) != NULL); +} + +/* CON/PRN/AUX/NUL/COM/LPT remain reserved when followed by an extension, so + * compare their portion before the first dot. + * https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + * CONIN$/CONOUT$ console device names: + * https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew#consoles + * CLOCK$ reserved device name: + * https://learn.microsoft.com/en-us/biztalk/core/restrictions-when-configuring-the-file-adapter */ +static BOOL wf_cliprdr_file_name_reserved_device(const WCHAR *component, SIZE_T length) +{ + const WCHAR *dot = wmemchr(component, L'.', length); + SIZE_T base_length = dot ? (SIZE_T)(dot - component) : length; + return wf_cliprdr_file_name_equals(component, length, L"CONIN$") || + wf_cliprdr_file_name_equals(component, length, L"CONOUT$") || + wf_cliprdr_file_name_equals(component, length, L"CLOCK$") || + wf_cliprdr_file_name_equals(component, base_length, L"CON") || + wf_cliprdr_file_name_equals(component, base_length, L"PRN") || + wf_cliprdr_file_name_equals(component, base_length, L"AUX") || + wf_cliprdr_file_name_equals(component, base_length, L"NUL") || + wf_cliprdr_file_name_numbered_device(component, base_length); +} + +static BOOL wf_cliprdr_file_name_component_valid(const WCHAR *component, SIZE_T length) +{ + SIZE_T i; + + /* Windows removes leading/trailing ASCII spaces and trailing periods. + * Reject them so a validated remote name cannot become a different local name. + * https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/file-folder-name-whitespace-characters */ + if (length == 0 || component[0] == L' ' || component[length - 1] == L'.' || + component[length - 1] == L' ') + return FALSE; + /* Path separators are parsed by the caller; reject other Win32-reserved + * punctuation and control characters here. + * https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file */ + for (i = 0; i < length; i++) + { + if (component[i] < L' ' || + wcschr(WF_CLIPRDR_INVALID_FILE_NAME_CHARS, component[i]) != NULL) + return FALSE; + } + return !wf_cliprdr_file_name_reserved_device(component, length); +} + +BOOL wf_cliprdr_file_descriptor_name_valid(const WCHAR *name) +{ + SIZE_T component_start = 0; + SIZE_T i; + + if (!name || name[0] == L'\\' || name[0] == L'/') + return FALSE; + + /* FILEDESCRIPTORW::cFileName is WCHAR[MAX_PATH]; reject names without a + * terminator within that fixed field. */ + for (i = 0; i < MAX_PATH; i++) + { + WCHAR value = name[i]; + if (value != L'\0' && value != L'\\' && value != L'/') + continue; + if (!wf_cliprdr_file_name_component_valid(&name[component_start], i - component_start)) + return FALSE; + if (value == L'\0') + return TRUE; + component_start = i + 1; + } + + return FALSE; +} + +static BOOL wf_cliprdr_file_group_descriptor_names_valid( + const FILEGROUPDESCRIPTORW *group, UINT count) +{ + UINT i; + + for (i = 0; i < count; i++) + { + if (!wf_cliprdr_file_descriptor_name_valid(group->fgd[i].cFileName)) + return FALSE; + } + + return TRUE; +} + +static BOOL wf_cliprdr_bounded_strlen(const char *value, size_t max_len, size_t *len) +{ + size_t i; + + if (!value || !len) + return FALSE; + + for (i = 0; i <= max_len; i++) + { + if (value[i] == '\0') + { + *len = i; + return TRUE; + } + } + + return FALSE; +} + /** * Clipboard Formats */ @@ -205,6 +357,7 @@ struct _CliprdrStream FILEDESCRIPTORW m_Dsc; void *m_pData; UINT32 m_connID; + UINT32 m_streamId; // unique CLIPRDR streamId; avoids leaking a heap pointer }; typedef struct _CliprdrStream CliprdrStream; @@ -241,6 +394,9 @@ struct wf_clipboard size_t map_size; size_t map_capacity; formatMapping *format_mappings; + /* Protects map replacement by Tokio callbacks against clipboard STA readers. + * ContextSend serializes callback processing, so callback-local reads need no lock. */ + SRWLOCK format_map_lock; UINT32 requestedFormatId; @@ -258,6 +414,10 @@ struct wf_clipboard char *req_fdata; HANDLE req_fevent; BOOL req_f_received; + UINT32 req_f_conn_id_expected; // connID of the outstanding request + UINT32 req_f_stream_id_expected; // streamId of the outstanding request; responses for another are dropped + ULONG req_fsize_expected; // maximum response size of the outstanding request + LONG req_f_stream_id_seq; // source of unique per-stream ids size_t nFiles; size_t file_array_size; @@ -277,10 +437,12 @@ typedef struct wf_clipboard wfClipboard; #define WM_CLIPRDR_MESSAGE (WM_USER + 156) #define OLE_SETCLIPBOARD 1 #define DELAYED_RENDERING 2 +#define OLE_EMPTYCLIPBOARD 3 BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr); BOOL wf_cliprdr_uninit(wfClipboard *clipboard, CliprdrClientContext *cliprdr); -BOOL wf_do_empty_cliprdr(wfClipboard *clipboard); +BOOL wf_do_empty_cliprdr(wfClipboard *clipboard, UINT32 connID); +static BOOL wf_empty_cliprdr_on_sta(wfClipboard *clipboard_ctx, UINT32 connID); static BOOL wf_create_file_obj(UINT32 *connID, wfClipboard *clipboard, IDataObject **ppDataObject); static void wf_destroy_file_obj(IDataObject *instance); @@ -288,7 +450,7 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format); static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UINT32 format); static UINT cliprdr_send_lock(wfClipboard *clipboard); static UINT cliprdr_send_unlock(wfClipboard *clipboard); -static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid, +static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId, ULONG index, UINT32 flag, DWORD positionhigh, DWORD positionlow, ULONG request); @@ -297,7 +459,8 @@ static BOOL is_set_by_instance(wfClipboard *clipboard); static void CliprdrDataObject_Delete(CliprdrDataObject *instance); -static CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc); +static HRESULT CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc, + CliprdrEnumFORMATETC **ppInstance); static void CliprdrEnumFORMATETC_Delete(CliprdrEnumFORMATETC *instance); static void CliprdrStream_Delete(CliprdrStream *instance); @@ -371,7 +534,7 @@ static ULONG STDMETHODCALLTYPE CliprdrStream_Release(IStream *This) static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULONG cb, ULONG *pcbRead) { - int ret; + UINT ret; CliprdrStream *instance = (CliprdrStream *)This; wfClipboard *clipboard; @@ -379,17 +542,31 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO return E_INVALIDARG; clipboard = (wfClipboard *)instance->m_pData; + if (!clipboard) + return E_UNEXPECTED; + *pcbRead = 0; if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart) return S_FALSE; - ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)This, instance->m_lIndex, + ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId, instance->m_lIndex, FILECONTENTS_RANGE, instance->m_lOffset.HighPart, instance->m_lOffset.LowPart, cb); - if (ret < 0) + if (ret != CHANNEL_RC_OK) + { + free(clipboard->req_fdata); + clipboard->req_fdata = NULL; return E_FAIL; + } + + if (clipboard->req_fsize > cb) + { + free(clipboard->req_fdata); + clipboard->req_fdata = NULL; + return STG_E_READFAULT; + } if (clipboard->req_fdata) { @@ -601,6 +778,7 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData, instance->m_pData = pData; instance->m_lOffset.QuadPart = 0; instance->m_connID = connID; + instance->m_streamId = (UINT32)InterlockedIncrement(&clipboard->req_f_stream_id_seq); if (instance->m_Dsc.dwFlags & FD_ATTRIBUTES) { @@ -611,16 +789,28 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData, if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir) { /* get content size of this stream */ - if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)instance, + if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId, instance->m_lIndex, FILECONTENTS_SIZE, 0, 0, 8) == CHANNEL_RC_OK) { success = TRUE; } - if (clipboard->req_fdata != NULL) + if (clipboard->req_fdata != NULL && clipboard->req_fsize >= sizeof(LONGLONG)) + { + LONGLONG sz = 0; + CopyMemory(&sz, clipboard->req_fdata, sizeof(sz)); + if (sz < 0) + success = FALSE; + else + instance->m_lSize.QuadPart = sz; + } + else + { + success = FALSE; + } + if (clipboard->req_fdata) { - instance->m_lSize.QuadPart = *((LONGLONG *)clipboard->req_fdata); free(clipboard->req_fdata); clipboard->req_fdata = NULL; } @@ -854,6 +1044,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO if (!wf_cliprdr_file_group_descriptor_size_valid(hmem_size, stream_count)) return wf_cliprdr_fail_locked_file_descriptor_data( clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED); + if (!wf_cliprdr_file_group_descriptor_names_valid(dsc, stream_count)) + return wf_cliprdr_fail_locked_file_descriptor_data( + clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED); streams = (IStream **)calloc(stream_count, sizeof(IStream *)); if (!streams) @@ -875,6 +1068,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO wf_cliprdr_reset_streams(instance); instance->m_pStream = streams; instance->m_nStreams = stream_count; + /* pUnkForRelease is NULL, so the caller now owns hGlobal. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; return S_OK; } else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS)) @@ -945,6 +1141,8 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_EnumFormatEtc(IDataObject *Th DWORD dwDirection, IEnumFORMATETC **ppenumFormatEtc) { + HRESULT result; + CliprdrEnumFORMATETC *enumerator; CliprdrDataObject *instance = (CliprdrDataObject *)This; if (!instance || !ppenumFormatEtc) @@ -952,9 +1150,10 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_EnumFormatEtc(IDataObject *Th if (dwDirection == DATADIR_GET) { - *ppenumFormatEtc = (IEnumFORMATETC *)CliprdrEnumFORMATETC_New(instance->m_nNumFormats, - instance->m_pFormatEtc); - return (*ppenumFormatEtc) ? S_OK : E_OUTOFMEMORY; + result = CliprdrEnumFORMATETC_New(instance->m_nNumFormats, + instance->m_pFormatEtc, &enumerator); + *ppenumFormatEtc = (IEnumFORMATETC *)enumerator; + return result; } else { @@ -1051,24 +1250,7 @@ static CliprdrDataObject *CliprdrDataObject_New(UINT32 connID, FORMATETC *fmtetc return instance; error: - if (iDataObject && iDataObject->lpVtbl) - { - free(iDataObject->lpVtbl); - } - if (instance) - { - if (instance->m_pFormatEtc) - { - free(instance->m_pFormatEtc); - } - - if (instance->m_pStgMedium) - { - free(instance->m_pStgMedium); - } - - CliprdrDataObject_Delete(instance); - } + CliprdrDataObject_Delete(instance); return NULL; } @@ -1132,17 +1314,29 @@ static void wf_destroy_file_obj(IDataObject *instance) * IEnumFORMATETC */ -static void cliprdr_format_deep_copy(FORMATETC *dest, FORMATETC *source) +static HRESULT cliprdr_format_deep_copy(FORMATETC *dest, const FORMATETC *source) { + SIZE_T target_device_size; + + if (!dest || !source) + return E_INVALIDARG; + *dest = *source; - if (source->ptd) - { - dest->ptd = (DVTARGETDEVICE *)CoTaskMemAlloc(sizeof(DVTARGETDEVICE)); + if (!source->ptd) + return S_OK; - if (dest->ptd) - *(dest->ptd) = *(source->ptd); - } + dest->ptd = NULL; + target_device_size = source->ptd->tdSize; + if (target_device_size < offsetof(DVTARGETDEVICE, tdData)) + return DV_E_DVTARGETDEVICE; + + dest->ptd = (DVTARGETDEVICE *)CoTaskMemAlloc(target_device_size); + if (!dest->ptd) + return E_OUTOFMEMORY; + + CopyMemory(dest->ptd, source->ptd, target_device_size); + return S_OK; } static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_QueryInterface(IEnumFORMATETC *This, @@ -1199,15 +1393,40 @@ static ULONG STDMETHODCALLTYPE CliprdrEnumFORMATETC_Release(IEnumFORMATETC *This static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Next(IEnumFORMATETC *This, ULONG celt, FORMATETC *rgelt, ULONG *pceltFetched) { + HRESULT result = S_OK; ULONG copied = 0; + LONG start_index; CliprdrEnumFORMATETC *instance = (CliprdrEnumFORMATETC *)This; if (!instance || !celt || !rgelt) return E_INVALIDARG; + start_index = instance->m_nIndex; while ((instance->m_nIndex < instance->m_nNumFormats) && (copied < celt)) { - cliprdr_format_deep_copy(&rgelt[copied++], &instance->m_pFormatEtc[instance->m_nIndex++]); + result = cliprdr_format_deep_copy(&rgelt[copied], + &instance->m_pFormatEtc[instance->m_nIndex]); + if (FAILED(result)) + break; + copied++; + instance->m_nIndex++; + } + + if (FAILED(result)) + { + while (copied > 0) + { + copied--; + if (rgelt[copied].ptd) + { + CoTaskMemFree(rgelt[copied].ptd); + rgelt[copied].ptd = NULL; + } + } + instance->m_nIndex = start_index; + if (pceltFetched != 0) + *pceltFetched = 0; + return result; } if (pceltFetched != 0) @@ -1223,10 +1442,11 @@ static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Skip(IEnumFORMATETC *This, if (!instance) return E_INVALIDARG; - if (instance->m_nIndex + (LONG)celt > instance->m_nNumFormats) + if (instance->m_nIndex < 0 || instance->m_nIndex > instance->m_nNumFormats || + celt > (ULONG)(instance->m_nNumFormats - instance->m_nIndex)) return E_FAIL; - instance->m_nIndex += celt; + instance->m_nIndex += (LONG)celt; return S_OK; } @@ -1244,29 +1464,40 @@ static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Reset(IEnumFORMATETC *This static HRESULT STDMETHODCALLTYPE CliprdrEnumFORMATETC_Clone(IEnumFORMATETC *This, IEnumFORMATETC **ppEnum) { + HRESULT result; + CliprdrEnumFORMATETC *clone; CliprdrEnumFORMATETC *instance = (CliprdrEnumFORMATETC *)This; if (!instance || !ppEnum) return E_INVALIDARG; - *ppEnum = - (IEnumFORMATETC *)CliprdrEnumFORMATETC_New(instance->m_nNumFormats, instance->m_pFormatEtc); - - if (!*ppEnum) - return E_OUTOFMEMORY; + result = CliprdrEnumFORMATETC_New(instance->m_nNumFormats, instance->m_pFormatEtc, + &clone); + if (FAILED(result)) + { + *ppEnum = NULL; + return result; + } - ((CliprdrEnumFORMATETC *)*ppEnum)->m_nIndex = instance->m_nIndex; + clone->m_nIndex = instance->m_nIndex; + *ppEnum = (IEnumFORMATETC *)clone; return S_OK; } -CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc) +static HRESULT CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc, + CliprdrEnumFORMATETC **ppInstance) { ULONG i; - CliprdrEnumFORMATETC *instance; + HRESULT result = E_OUTOFMEMORY; + CliprdrEnumFORMATETC *instance = NULL; IEnumFORMATETC *iEnumFORMATETC; + if (!ppInstance) + return E_INVALIDARG; + + *ppInstance = NULL; if ((nFormats != 0) && !pFormatEtc) - return NULL; + return E_INVALIDARG; instance = (CliprdrEnumFORMATETC *)calloc(1, sizeof(CliprdrEnumFORMATETC)); @@ -1298,13 +1529,18 @@ CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pForma goto error; for (i = 0; i < nFormats; i++) - cliprdr_format_deep_copy(&instance->m_pFormatEtc[i], &pFormatEtc[i]); + { + result = cliprdr_format_deep_copy(&instance->m_pFormatEtc[i], &pFormatEtc[i]); + if (FAILED(result)) + goto error; + } } - return instance; + *ppInstance = instance; + return S_OK; error: CliprdrEnumFORMATETC_Delete(instance); - return NULL; + return result; } void CliprdrEnumFORMATETC_Delete(CliprdrEnumFORMATETC *instance) @@ -1391,42 +1627,59 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format) { UINT32 i; formatMapping *map; + UINT32 result = local_format; if (!clipboard) return 0; + AcquireSRWLockShared(&clipboard->format_map_lock); for (i = 0; i < clipboard->map_size; i++) { map = &clipboard->format_mappings[i]; if (map->local_format_id == local_format) - return map->remote_format_id; + { + result = map->remote_format_id; + break; + } } + ReleaseSRWLockShared(&clipboard->format_map_lock); - return local_format; + return result; } -static void map_ensure_capacity(wfClipboard *clipboard) +static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity) { + size_t old_size; + formatMapping *new_map; + if (!clipboard) - return; + return FALSE; - if (clipboard->map_size >= clipboard->map_capacity) - { - size_t new_size; - formatMapping *new_map; - new_size = clipboard->map_capacity * 2; - new_map = - (formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * new_size); + if (!clipboard->format_mappings) + return FALSE; - if (!new_map) - return; + if (capacity <= clipboard->map_capacity) + return TRUE; - clipboard->format_mappings = new_map; - clipboard->map_capacity = new_size; - } + if (capacity > WF_CLIPRDR_MAX_FORMATS || + capacity > ((size_t)-1) / sizeof(formatMapping)) + return FALSE; + + old_size = clipboard->map_capacity; + new_map = + (formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * capacity); + + if (!new_map) + return FALSE; + + memset(new_map + old_size, 0, sizeof(formatMapping) * (capacity - old_size)); + clipboard->format_mappings = new_map; + clipboard->map_capacity = capacity; + return TRUE; } +/* Requires format_map_lock until the clipboard STA thread has exited. */ static BOOL clear_format_map(wfClipboard *clipboard) { size_t i; @@ -1508,7 +1761,6 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) int count = 0; UINT32 index; UINT32 numFormats = 0; - UINT32 formatId = 0; char formatName[1024]; CLIPRDR_FORMAT *formats = NULL; CLIPRDR_FORMAT_LIST formatList = {0}; @@ -1526,6 +1778,13 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) /* Ignore if other app is holding clipboard */ if (try_open_clipboard(clipboard->hwnd)) { + if (!IsClipboardFormatAvailable(CF_HDROP)) + { + if (!CloseClipboard()) + return ERROR_INTERNAL_ERROR; + return ERROR_SUCCESS; + } + // If current process is running as service with SYSTEM user. // Clipboard api works fine for text, but copying files works no good. // GetLastError() returns various error codes @@ -1537,6 +1796,8 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) } numFormats = (UINT32)count; + if (numFormats < WF_CLIPRDR_FILE_FORMAT_COUNT) + numFormats = WF_CLIPRDR_FILE_FORMAT_COUNT; formats = (CLIPRDR_FORMAT *)calloc(numFormats, sizeof(CLIPRDR_FORMAT)); if (!formats) @@ -1549,6 +1810,12 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) // IsClipboardFormatAvailable(CF_HDROP) is checked above UINT fsid = RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW); UINT fcid = RegisterClipboardFormat(CFSTR_FILECONTENTS); + if (!fsid || !fcid) + { + CloseClipboard(); + free(formats); + return ERROR_INTERNAL_ERROR; + } formats[index++].formatId = fsid; formats[index++].formatId = fcid; numFormats = index; @@ -1656,7 +1923,7 @@ UINT wait_response_event(UINT32 connID, wfClipboard *clipboard, HANDLE event, BO if (clipboard->context->IsStopped == TRUE) { - wf_do_empty_cliprdr(clipboard); + wf_do_empty_cliprdr(clipboard, 0); rc = ERROR_INTERNAL_ERROR; } @@ -1729,12 +1996,12 @@ static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UIN return wait_response_event(connID, clipboard, clipboard->formatDataRespEvent, &clipboard->formatDataRespReceived, &clipboard->hmem); } -UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid, ULONG index, +static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId, ULONG index, UINT32 flag, DWORD positionhigh, DWORD positionlow, ULONG nreq) { UINT rc; - CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest; + CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest = { 0 }; if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsRequest) return ERROR_INTERNAL_ERROR; @@ -1745,12 +2012,12 @@ UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, co return rc; } clipboard->req_f_received = FALSE; + clipboard->req_f_conn_id_expected = connID; + clipboard->req_f_stream_id_expected = streamId; + clipboard->req_fsize_expected = nreq; fileContentsRequest.connID = connID; - // streamId is `IStream*` pointer, though it is not very good on a 64-bit system. - // But it is OK, because it is only used to check if the stream is the same in - // `wf_cliprdr_server_file_contents_request()` function. - fileContentsRequest.streamId = (UINT32)(ULONG_PTR)streamid; + fileContentsRequest.streamId = streamId; fileContentsRequest.listIndex = index; fileContentsRequest.dwFlags = flag; fileContentsRequest.nPositionLow = positionlow; @@ -1778,11 +2045,7 @@ static UINT cliprdr_send_response_filecontents( CLIPRDR_FILE_CONTENTS_RESPONSE fileContentsResponse; if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsResponse) - { - data = NULL; - size = 0; - msgFlags = CB_RESPONSE_FAIL; - } + return ERROR_INTERNAL_ERROR; fileContentsResponse.connID = connID; fileContentsResponse.streamId = streamId; @@ -1875,11 +2138,12 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM if (clipboard->hmem) { GlobalFree(clipboard->hmem); - clipboard->hmem = NULL; } } - /* Note: GlobalFree() is not needed when success */ + /* SetClipboardData owns hmem on success; the failure path frees it above. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; break; case WM_DRAWCLIPBOARD: @@ -1946,6 +2210,13 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM break; + case OLE_EMPTYCLIPBOARD: + DEBUG_CLIPRDR("info: OLE_EMPTYCLIPBOARD"); + if (!wf_empty_cliprdr_on_sta(clipboard, (UINT32)(UINT_PTR)lParam)) + DEBUG_CLIPRDR("OLE_EMPTYCLIPBOARD failed for connection %u", + (UINT32)(UINT_PTR)lParam); + break; + case DELAYED_RENDERING: FORMAT_IDS *format_ids = (FORMAT_IDS *)lParam; if (!try_open_clipboard(clipboard->hwnd)) @@ -1972,9 +2243,11 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM if (clipboard->hmem) { GlobalFree(clipboard->hmem); - clipboard->hmem = NULL; } } + /* SetClipboardData owns hmem on success; the failure path frees it above. */ + clipboard->hmem = NULL; + clipboard->hmem_data_len = 0; } if (!CloseClipboard() && GetLastError()) @@ -2235,6 +2508,9 @@ static BOOL wf_cliprdr_array_ensure_capacity(wfClipboard *clipboard) static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_file_name, size_t pathLen) { + if (!clipboard || clipboard->nFiles >= WF_CLIPRDR_MAX_STREAMS) + return FALSE; + if (!wf_cliprdr_array_ensure_capacity(clipboard)) return FALSE; @@ -2273,7 +2549,7 @@ static BOOL wf_cliprdr_traverse_directory(wfClipboard *clipboard, WCHAR *Dir, si { HANDLE hFind; WCHAR DirSpec[MAX_PATH]; - WIN32_FIND_DATA FindFileData; + WIN32_FIND_DATAW FindFileData; if (!clipboard || !Dir) return FALSE; @@ -2309,33 +2585,37 @@ static BOOL wf_cliprdr_traverse_directory(wfClipboard *clipboard, WCHAR *Dir, si { WCHAR DirAdd[MAX_PATH]; if (wcslen(Dir) + wcslen(FindFileData.cFileName) + 2 > MAX_PATH) - return FALSE; + goto fail; StringCchCopyW(DirAdd, MAX_PATH, Dir); StringCchCatW(DirAdd, MAX_PATH, L"\\"); StringCchCatW(DirAdd, MAX_PATH, FindFileData.cFileName); if (!wf_cliprdr_add_to_file_arrays(clipboard, DirAdd, pathLen)) - return FALSE; + goto fail; if (!wf_cliprdr_traverse_directory(clipboard, DirAdd, pathLen)) - return FALSE; + goto fail; } else { WCHAR fileName[MAX_PATH]; if (wcslen(Dir) + wcslen(FindFileData.cFileName) + 2 > MAX_PATH) - return FALSE; + goto fail; StringCchCopyW(fileName, MAX_PATH, Dir); StringCchCatW(fileName, MAX_PATH, L"\\"); StringCchCatW(fileName, MAX_PATH, FindFileData.cFileName); if (!wf_cliprdr_add_to_file_arrays(clipboard, fileName, pathLen)) - return FALSE; + goto fail; } } FindClose(hFind); return TRUE; + +fail: + FindClose(hFind); + return FALSE; } static UINT wf_cliprdr_send_client_capabilities(wfClipboard *clipboard) @@ -2372,11 +2652,15 @@ static UINT wf_cliprdr_monitor_ready(CliprdrClientContext *context, const CLIPRDR_MONITOR_READY *monitorReady) { UINT rc; - wfClipboard *clipboard = (wfClipboard *)context->Custom; + wfClipboard *clipboard; if (!context || !monitorReady) return ERROR_INTERNAL_ERROR; + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) + return ERROR_INTERNAL_ERROR; + clipboard->sync = TRUE; rc = wf_cliprdr_send_client_capabilities(clipboard); @@ -2398,9 +2682,15 @@ static UINT wf_cliprdr_server_capabilities(CliprdrClientContext *context, { UINT32 index; CLIPRDR_CAPABILITY_SET *capabilitySet; - wfClipboard *clipboard = (wfClipboard *)context->Custom; + wfClipboard *clipboard; - if (!context || !capabilities) + if (!context || !capabilities || + capabilities->cCapabilitiesSets > 1 || + (capabilities->cCapabilitiesSets == 1 && !capabilities->capabilitySets)) + return ERROR_INTERNAL_ERROR; + + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) return ERROR_INTERNAL_ERROR; for (index = 0; index < capabilities->cCapabilitiesSets; index++) @@ -2441,8 +2731,19 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, if (!clipboard) return ERROR_INTERNAL_ERROR; + AcquireSRWLockExclusive(&clipboard->format_map_lock); if (!clear_format_map(clipboard)) - return ERROR_INTERNAL_ERROR; + goto unlock_fail; + clipboard->copied = FALSE; + + if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS) + goto fail; + + if (formatList->numFormats > 0 && !formatList->formats) + goto fail; + + if (!map_ensure_capacity(clipboard, formatList->numFormats)) + goto fail; clipboard->copied = TRUE; @@ -2450,19 +2751,58 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, { format = &(formatList->formats[i]); mapping = &(clipboard->format_mappings[i]); + /* Do not validate the peer-provided formatId as a Windows registered format. + * It is only a remote protocol ID used when requesting data from the peer. + * For named formats, RegisterClipboardFormatW creates the local Windows + * clipboard ID below, and that local ID is checked before publishing. */ mapping->remote_format_id = format->formatId; if (format->formatName) { - int size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, - strlen(format->formatName), NULL, 0); - mapping->name = calloc(size + 1, sizeof(WCHAR)); + size_t name_len; + int size; - if (mapping->name) + if (!wf_cliprdr_bounded_strlen(format->formatName, + WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len)) { - MultiByteToWideChar(CP_UTF8, 0, format->formatName, strlen(format->formatName), - mapping->name, size); - mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); + goto fail; + } + + if (name_len == 0) + { + goto fail; + } + + size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, + NULL, 0); + if (size <= 0) + { + goto fail; + } + + if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS) + { + goto fail; + } + + mapping->name = calloc((size_t)size + 1, sizeof(WCHAR)); + if (!mapping->name) + { + goto fail; + } + + if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, + mapping->name, size) != size) + { + free(mapping->name); + mapping->name = NULL; + goto fail; + } + + mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); + if (mapping->local_format_id == 0) + { + goto fail; } } else @@ -2472,8 +2812,8 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } clipboard->map_size++; - map_ensure_capacity(clipboard); } + ReleaseSRWLockExclusive(&clipboard->format_map_lock); if (file_transferring(clipboard)) { @@ -2484,6 +2824,8 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, *p_conn_id = formatList->connID; if (PostMessage(clipboard->hwnd, WM_CLIPRDR_MESSAGE, OLE_SETCLIPBOARD, p_conn_id)) rc = CHANNEL_RC_OK; + else + free(p_conn_id); } } else @@ -2522,11 +2864,14 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } else { + free(format_ids->formats); + free(format_ids); rc = ERROR_INTERNAL_ERROR; } } else { + free(format_ids); rc = ERROR_INTERNAL_ERROR; } } @@ -2546,6 +2891,13 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } return rc; + +fail: + clear_format_map(clipboard); +unlock_fail: + clipboard->copied = FALSE; + ReleaseSRWLockExclusive(&clipboard->format_map_lock); + return ERROR_INTERNAL_ERROR; } /** @@ -2558,7 +2910,9 @@ wf_cliprdr_server_format_list_response(CliprdrClientContext *context, const CLIPRDR_FORMAT_LIST_RESPONSE *formatListResponse) { (void)context; - (void)formatListResponse; + + if (!formatListResponse) + return ERROR_INTERNAL_ERROR; if (formatListResponse->msgFlags != CB_RESPONSE_OK) return E_FAIL; @@ -2647,16 +3001,15 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (!context || !formatDataRequest) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } clipboard = (wfClipboard *)context->Custom; - if (!clipboard) + if (!clipboard || !clipboard->context || + !clipboard->context->ClientFormatDataResponse) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } requestedFormatId = formatDataRequest->requestedFormatId; @@ -2665,8 +3018,11 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, { size_t len; size_t i; + SIZE_T dropFilesSize; + SIZE_T remaining; WCHAR *wFileName; HRESULT result; + BOOL fileListValid = FALSE; LPDATAOBJECT dataObj; FORMATETC format_etc; STGMEDIUM stg_medium; @@ -2691,6 +3047,7 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (FAILED(result)) { + IDataObject_Release(dataObj); rc = ERROR_INTERNAL_ERROR; goto exit; } @@ -2699,58 +3056,105 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (!dropFiles) { - GlobalUnlock(stg_medium.hGlobal); + clear_file_array(clipboard); ReleaseStgMedium(&stg_medium); - clipboard->nFiles = 0; - goto resp; + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; } clear_file_array(clipboard); - - if (dropFiles->fWide) - { - /* dropFiles contains file names */ - for (wFileName = (WCHAR *)((char *)dropFiles + dropFiles->pFiles); - (len = wcslen(wFileName)) > 0; wFileName += len + 1) + /* HGLOBAL layout: + * [DROPFILES header][optional padding][double-NUL-terminated file list] + * ^ offset 0 ^ byte offset pFiles + * pFiles is an offset, not a pointer: + * https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles + * Keep remaining in bytes, parse within the HGLOBAL bounds, and accept only + * after the empty terminator is found. */ + dropFilesSize = GlobalSize(stg_medium.hGlobal); + if (dropFilesSize >= sizeof(DROPFILES) && + dropFiles->pFiles >= sizeof(DROPFILES) && + (SIZE_T)dropFiles->pFiles < dropFilesSize) + { + remaining = dropFilesSize - dropFiles->pFiles; + if (dropFiles->fWide && (dropFiles->pFiles % sizeof(WCHAR)) == 0) { - wf_cliprdr_process_filename(clipboard, wFileName, wcslen(wFileName)); + wFileName = (WCHAR *)((BYTE *)dropFiles + dropFiles->pFiles); + while (remaining >= sizeof(WCHAR)) + { + if (FAILED(StringCchLengthW( + wFileName, remaining / sizeof(WCHAR), &len))) + break; + if (len == 0) + { + fileListValid = TRUE; + break; + } + if (!wf_cliprdr_process_filename(clipboard, wFileName, len)) + break; + wFileName += len + 1; + remaining -= (len + 1) * sizeof(WCHAR); + } } - } - else - { - char *p; - for (p = (char *)((char *)dropFiles + dropFiles->pFiles); (len = strlen(p)) > 0; - p += len + 1, clipboard->nFiles++) + else if (!dropFiles->fWide) { - int cchWideChar; - cchWideChar = MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, NULL, 0); - wFileName = (LPWSTR)calloc(cchWideChar, sizeof(WCHAR)); - if (wFileName) + char *name = (char *)dropFiles + dropFiles->pFiles; + while (remaining > 0) { - MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, wFileName, cchWideChar); - wf_cliprdr_process_filename(clipboard, wFileName, cchWideChar); + int wideLen; + if (FAILED(StringCchLengthA(name, remaining, &len))) + break; + if (len == 0) + { + fileListValid = TRUE; + break; + } + wideLen = MultiByteToWideChar( + CP_ACP, MB_COMPOSITE, name, (int)len, NULL, 0); + if (wideLen <= 0) + break; + wFileName = (WCHAR *)calloc((size_t)wideLen + 1, sizeof(WCHAR)); + if (!wFileName) + break; + if (MultiByteToWideChar(CP_ACP, MB_COMPOSITE, name, + (int)len, wFileName, wideLen) != wideLen || + !wf_cliprdr_process_filename( + clipboard, wFileName, (size_t)wideLen)) + { + free(wFileName); + break; + } free(wFileName); - } - else - { - rc = ERROR_INTERNAL_ERROR; - GlobalUnlock(stg_medium.hGlobal); - ReleaseStgMedium(&stg_medium); - goto exit; + name += len + 1; + remaining -= len + 1; } } } GlobalUnlock(stg_medium.hGlobal); ReleaseStgMedium(&stg_medium); - resp: - // size will not overflow, because size type is size_t (unsigned __int64) - size = 4 + clipboard->nFiles * sizeof(FILEDESCRIPTORW); - groupDsc = (FILEGROUPDESCRIPTORW *)malloc(size); + if (!fileListValid) + { + clear_file_array(clipboard); + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + if (clipboard->nFiles == 0 || + clipboard->nFiles > WF_CLIPRDR_MAX_STREAMS) + { + IDataObject_Release(dataObj); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + /* FILEGROUPDESCRIPTORW has a variable-length fgd[] tail. */ + size = offsetof(FILEGROUPDESCRIPTORW, fgd) + + clipboard->nFiles * sizeof(FILEDESCRIPTORW); + groupDsc = (FILEGROUPDESCRIPTORW *)calloc(1, size); if (groupDsc) { - groupDsc->cItems = clipboard->nFiles; + groupDsc->cItems = (UINT)clipboard->nFiles; for (i = 0; i < clipboard->nFiles; i++) { @@ -2759,10 +3163,15 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, } buff = groupDsc; + rc = ERROR_SUCCESS; + } + else + { + size = 0; + rc = CHANNEL_RC_NO_MEMORY; } IDataObject_Release(dataObj); - rc = ERROR_SUCCESS; } else { @@ -2782,7 +3191,20 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, else { globlemem = (char *)GlobalLock(hClipdata); - size = (int)GlobalSize(hClipdata); + if (!globlemem) + { + CloseClipboard(); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + size = GlobalSize(hClipdata); + if (!wf_cliprdr_format_data_size_valid(size)) + { + GlobalUnlock(hClipdata); + CloseClipboard(); + rc = ERROR_INTERNAL_ERROR; + goto exit; + } buff = malloc(size); if (buff) { @@ -2804,6 +3226,9 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, } exit: + if (rc != ERROR_SUCCESS) + size = 0; + if (rc == ERROR_SUCCESS) { response.msgFlags = CB_RESPONSE_OK; @@ -2813,7 +3238,7 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, response.msgFlags = CB_RESPONSE_FAIL; } response.connID = formatDataRequest->connID; - response.dataLen = size; + response.dataLen = (UINT32)size; response.requestedFormatData = (BYTE *)buff; if (ERROR_SUCCESS != clipboard->context->ClientFormatDataResponse(clipboard->context, &response)) { @@ -2839,7 +3264,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, UINT rc = ERROR_INTERNAL_ERROR; BYTE *data; HANDLE hMem; - wfClipboard *clipboard; + wfClipboard *clipboard = NULL; do { @@ -2866,6 +3291,13 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, break; } + if (formatDataResponse->dataLen > 0 && + !formatDataResponse->requestedFormatData) + { + rc = ERROR_INTERNAL_ERROR; + break; + } + hMem = GlobalAlloc(GMEM_MOVEABLE, formatDataResponse->dataLen); if (!hMem) { @@ -2895,6 +3327,8 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context, rc = CHANNEL_RC_OK; } while (0); + if (!clipboard) + return rc; if (!SetEvent(clipboard->formatDataRespEvent)) { // If failed to set event, set flag to indicate the event is received. @@ -2923,6 +3357,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, BOOL bIsStreamFile = TRUE; static LPSTREAM pStreamStc = NULL; static UINT32 uStreamIdStc = 0; + static UINT32 uConnIdStc = 0; wfClipboard *clipboard; UINT rc = ERROR_INTERNAL_ERROR; UINT sRc; @@ -2930,16 +3365,15 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, if (!context || !fileContentsRequest) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } clipboard = (wfClipboard *)context->Custom; - if (!clipboard) + if (!clipboard || !clipboard->context || + !clipboard->context->ClientFileContentsResponse) { - rc = ERROR_INTERNAL_ERROR; - goto exit; + return ERROR_INTERNAL_ERROR; } // If the clipboard is set by the instance, or the file descriptor is from remote, @@ -2996,7 +3430,8 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, vFormatEtc.lindex = fileContentsRequest->listIndex; vFormatEtc.ptd = NULL; - if ((uStreamIdStc != fileContentsRequest->streamId) || !pStreamStc) + if ((uStreamIdStc != fileContentsRequest->streamId) || + (uConnIdStc != fileContentsRequest->connID) || !pStreamStc) { LPENUMFORMATETC pEnumFormatEtc; ULONG CeltFetched; @@ -3027,6 +3462,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, { pStreamStc = vStgMedium.pstm; uStreamIdStc = fileContentsRequest->streamId; + uConnIdStc = fileContentsRequest->connID; bIsStreamFile = TRUE; } @@ -3057,7 +3493,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, LARGE_INTEGER dlibMove; ULARGE_INTEGER dlibNewPosition; - if (clipboard->nFiles > 0 && + if (clipboard->context->HandleClipboardFiles && clipboard->nFiles > 0 && fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && fileContentsRequest->nPositionLow == 0 && fileContentsRequest->nPositionHigh == 0) { @@ -3068,8 +3504,11 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, dlibMove.LowPart = fileContentsRequest->nPositionLow; hRet = IStream_Seek(pStreamStc, dlibMove, STREAM_SEEK_SET, &dlibNewPosition); - if (SUCCEEDED(hRet)) - hRet = IStream_Read(pStreamStc, pData, cbRequested, (PULONG)&uSize); + if (FAILED(hRet)) + goto exit; + hRet = IStream_Read(pStreamStc, pData, cbRequested, (PULONG)&uSize); + if (FAILED(hRet) || uSize > cbRequested) + goto exit; } } else @@ -3096,7 +3535,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, goto exit; } - if (clipboard->nFiles > 0 && + if (clipboard->context->HandleClipboardFiles && clipboard->nFiles > 0 && fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && fileContentsRequest->nPositionLow == 0 && fileContentsRequest->nPositionHigh == 0) { @@ -3173,7 +3612,7 @@ static UINT wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, const CLIPRDR_FILE_CONTENTS_RESPONSE *fileContentsResponse) { - wfClipboard *clipboard; + wfClipboard *clipboard = NULL; UINT rc = ERROR_INTERNAL_ERROR; do @@ -3190,6 +3629,9 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, rc = ERROR_INTERNAL_ERROR; break; } + if (fileContentsResponse->connID != clipboard->req_f_conn_id_expected || + fileContentsResponse->streamId != clipboard->req_f_stream_id_expected) + return CHANNEL_RC_OK; clipboard->req_fsize = 0; clipboard->req_fdata = NULL; @@ -3198,8 +3640,26 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, rc = E_FAIL; break; } + if (fileContentsResponse->cbRequested > 0 && + !fileContentsResponse->requestedData) + { + rc = ERROR_INTERNAL_ERROR; + break; + } + if (fileContentsResponse->cbRequested > clipboard->req_fsize_expected) + { + rc = ERROR_INVALID_DATA; + break; + } clipboard->req_fsize = fileContentsResponse->cbRequested; + /* + * Keep the zero-size allocation: supported Windows builds use the Microsoft + * CRT, where malloc(0) returns a valid pointer. wait_response_event() also + * uses a non-NULL req_fdata to recognize a successful zero-byte response. + * The Rust FFI derives requestedData and cbRequested from the same Vec, so a + * nonzero length cannot have a NULL data pointer on the normal call path. + */ clipboard->req_fdata = (char *)malloc(fileContentsResponse->cbRequested); if (!clipboard->req_fdata) { @@ -3213,6 +3673,8 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, rc = CHANNEL_RC_OK; } while (0); + if (!clipboard) + return rc; if (!SetEvent(clipboard->req_fevent)) { // If failed to set event, set flag to indicate the event is received. @@ -3224,10 +3686,31 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, BOOL is_set_by_instance(wfClipboard *clipboard) { - if (GetClipboardOwner() == clipboard->hwnd || S_OK == OleIsCurrentClipboard(clipboard->data_obj)) { + IDataObject *data_obj = NULL; + BOOL is_current; + + if (!clipboard) + return FALSE; + if (GetClipboardOwner() == clipboard->hwnd) return TRUE; + if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + /* OLE_SETCLIPBOARD may replace data_obj after the mutex is released, so keep + * a temporary COM reference for the OLE call below. */ + data_obj = clipboard->data_obj; + if (data_obj) + IDataObject_AddRef(data_obj); + if (!ReleaseMutex(clipboard->data_obj_mutex)) + { + if (data_obj) + IDataObject_Release(data_obj); + return FALSE; } - return FALSE; + if (!data_obj) + return FALSE; + is_current = OleIsCurrentClipboard(data_obj) == S_OK; + IDataObject_Release(data_obj); + return is_current; } BOOL is_file_descriptor_from_remote() @@ -3255,6 +3738,7 @@ BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr) clipboard->hUser32 = LoadLibraryA("user32.dll"); clipboard->data_obj = NULL; clipboard->copied = FALSE; + InitializeSRWLock(&clipboard->format_map_lock); if (clipboard->hUser32) { @@ -3378,8 +3862,6 @@ BOOL uninit_cliprdr(CliprdrClientContext *context) BOOL empty_cliprdr(CliprdrClientContext *context, UINT32 connID) { wfClipboard *clipboard = NULL; - CliprdrDataObject *instance = NULL; - BOOL rc = FALSE; if (!context) { return FALSE; @@ -3395,67 +3877,113 @@ BOOL empty_cliprdr(CliprdrClientContext *context, UINT32 connID) return FALSE; } - instance = clipboard->data_obj; - if (instance) - { - if (instance->m_connID != connID) - { - return TRUE; - } - } - - return wf_do_empty_cliprdr(clipboard); + return wf_do_empty_cliprdr(clipboard, connID); } -BOOL wf_do_empty_cliprdr(wfClipboard *clipboard) +BOOL wf_do_empty_cliprdr(wfClipboard *clipboard, UINT32 connID) { - BOOL rc = FALSE; - if (!clipboard) - { + if (!clipboard || !clipboard->hwnd) return FALSE; - } - - clipboard->copied = FALSE; - if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + /* Always queue this operation. Besides releasing ContextSend immediately, this + * prevents OpenClipboard from running inside a WM_RENDERFORMAT handler. */ + if (!PostMessage(clipboard->hwnd, WM_CLIPRDR_MESSAGE, + OLE_EMPTYCLIPBOARD, (LPARAM)(UINT_PTR)connID)) { + DEBUG_CLIPRDR("PostMessage OLE_EMPTYCLIPBOARD failed with 0x%x", GetLastError()); return FALSE; } + return TRUE; +} - do +static BOOL wf_release_data_obj_if_same(wfClipboard *clipboard_ctx, IDataObject *expected) +{ + if (WaitForSingleObject(clipboard_ctx->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + if (clipboard_ctx->data_obj == expected) { - if (clipboard->data_obj != NULL) - { - wf_destroy_file_obj(clipboard->data_obj); - clipboard->data_obj = NULL; - } + clipboard_ctx->data_obj = NULL; + wf_destroy_file_obj(expected); + } + return ReleaseMutex(clipboard_ctx->data_obj_mutex); +} - /* discard all contexts in clipboard */ - if (!try_open_clipboard(clipboard->hwnd)) - { - DEBUG_CLIPRDR("OpenClipboard failed with 0x%x", GetLastError()); - rc = FALSE; - break; - } +static BOOL wf_empty_clipboard_on_sta(wfClipboard *clipboard_ctx, IDataObject *instance) +{ + HRESULT current = S_OK; + DWORD clipboard_sequence = GetClipboardSequenceNumber(); + BOOL close_succeeded; + BOOL result = TRUE; - if (is_file_descriptor_from_remote()) + if (instance) + { + current = OleIsCurrentClipboard(instance); + if (current != S_OK) { - if (!EmptyClipboard()) + if (current != S_FALSE) { - rc = FALSE; + DEBUG_CLIPRDR("OleIsCurrentClipboard failed with 0x%x", current); + result = FALSE; } + else if (!wf_release_data_obj_if_same(clipboard_ctx, instance)) + result = FALSE; + IDataObject_Release(instance); + return result; } + } - if (!CloseClipboard()) - { - // critical error!!! - } - rc = TRUE; - } while (0); + /* Clipboard calls can synchronously dispatch messages to another STA. */ + if (!try_open_clipboard(clipboard_ctx->hwnd)) + { + DEBUG_CLIPRDR("OpenClipboard failed with 0x%x", GetLastError()); + if (instance) + IDataObject_Release(instance); + return FALSE; + } - if (!ReleaseMutex(clipboard->data_obj_mutex)) + /* OpenClipboard stabilizes the contents; do not clear if they changed while opening. */ + if (clipboard_sequence == GetClipboardSequenceNumber() && + (instance || is_file_descriptor_from_remote()) && !EmptyClipboard()) { - // critical error!!! + DEBUG_CLIPRDR("EmptyClipboard failed with 0x%x", GetLastError()); + result = FALSE; } - return rc; + + close_succeeded = CloseClipboard(); + if (!close_succeeded) + DEBUG_CLIPRDR("CloseClipboard failed with 0x%x", GetLastError()); + if (instance) + { + if (result && !wf_release_data_obj_if_same(clipboard_ctx, instance)) + result = FALSE; + IDataObject_Release(instance); + } + + return close_succeeded && result; +} + +static BOOL wf_empty_cliprdr_on_sta(wfClipboard *clipboard_ctx, UINT32 connID) +{ + CliprdrDataObject *instance; + + if (!clipboard_ctx) + return FALSE; + if (WaitForSingleObject(clipboard_ctx->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + + instance = (CliprdrDataObject *)clipboard_ctx->data_obj; + /* Without a tracked object, continue so stale remote file formats can still be cleared. */ + if (connID != 0 && instance && instance->m_connID != connID) + return ReleaseMutex(clipboard_ctx->data_obj_mutex); + + clipboard_ctx->copied = FALSE; + if (instance) + IDataObject_AddRef((IDataObject *)instance); + if (!ReleaseMutex(clipboard_ctx->data_obj_mutex)) + { + if (instance) + IDataObject_Release((IDataObject *)instance); + return FALSE; + } + return wf_empty_clipboard_on_sta(clipboard_ctx, (IDataObject *)instance); } diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index c16be3469f9..4e379407fbe 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -42,6 +42,13 @@ impl Enigo { &mut self.custom_mouse } + /// Override the display server guessed in `Default::default`: on "x11" every method here + /// routes to `xdo`, and a null xdo context makes all of them silent no-ops. A caller + /// installing custom devices knows better than the guess. + pub fn set_is_x11(&mut self, is_x11: bool) { + self.is_x11 = is_x11; + } + /// Clear remapped keycodes pub fn tfc_clear_remapped(&mut self) { if let Some(tfc) = &mut self.tfc { @@ -390,3 +397,52 @@ fn test_key_seq() { let mut en = Enigo::new(); en.key_sequence("^^"); } + +/// Both directions: the failure is silent, so a one-directional test passes against the bug. +#[test] +fn test_custom_mouse_dispatch_follows_is_x11() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + struct CountingMouse(Arc); + impl MouseControllable for CountingMouse { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_mut_any(&mut self) -> &mut dyn std::any::Any { + self + } + fn mouse_move_to(&mut self, _x: i32, _y: i32) { + self.0.fetch_add(1, Ordering::Relaxed); + } + fn mouse_move_relative(&mut self, _x: i32, _y: i32) {} + fn mouse_down(&mut self, _button: MouseButton) -> crate::ResultType { + Ok(()) + } + fn mouse_up(&mut self, _button: MouseButton) {} + fn mouse_click(&mut self, _button: MouseButton) {} + fn mouse_scroll_x(&mut self, _length: i32) {} + fn mouse_scroll_y(&mut self, _length: i32) {} + } + + let calls = Arc::new(AtomicUsize::new(0)); + let mut en = Enigo::new(); + en.set_custom_mouse(Box::new(CountingMouse(calls.clone()))); + + en.set_is_x11(false); + en.mouse_move_to(10, 20); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was not reached on the non-x11 branch" + ); + + // Negative control: on the x11 branch the custom device must be bypassed entirely. + en.set_is_x11(true); + en.mouse_move_to(30, 40); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "custom mouse was reached on the x11 branch" + ); +} diff --git a/libs/hbb_common b/libs/hbb_common index a920d00945e..f124c0a5d49 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 +Subproject commit f124c0a5d49a4a13381902124b65364ff28fa541 diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 73deecd6727..aacfdcf9bb5 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.8" +version = "1.4.9" edition = "2021" description = "RustDesk Remote Desktop" @@ -26,7 +26,7 @@ windows = { version = "0.61", features = [ native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]} [package.metadata.winres] -LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved." +LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." ProductName = "RustDesk" OriginalFilename = "rustdesk.exe" FileDescription = "RustDesk Remote Desktop" diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2def8..bab2b4e9f2c 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -11,6 +11,16 @@ edition = "2018" [features] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] +# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) +# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is +# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.4). We deliberately do +# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree +# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. +# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of +# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always +# enable `scrap/wayland`, which is what hid this. +drm = ["wayland", "hbb_common/wayland_probe"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] @@ -48,7 +58,7 @@ quest = "0.3" [build-dependencies] target_build_utils = "0.3" -bindgen = "0.65" +bindgen = "0.72.1" pkg-config = { version = "0.3.27", optional = true } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/libs/scrap/src/common/aom.rs b/libs/scrap/src/common/aom.rs index e5093e54bb9..1ecd6059bf3 100644 --- a/libs/scrap/src/common/aom.rs +++ b/libs/scrap/src/common/aom.rs @@ -79,6 +79,10 @@ mod webrtc { } } + fn tile_log2(threads: u32) -> std::os::raw::c_uint { + (threads as f64).log2().ceil() as _ + } + fn get_super_block_size(width: u32, height: u32, threads: u32) -> aom_superblock_size_t { use aom_superblock_size::*; let resolution = width * height; @@ -160,8 +164,7 @@ mod webrtc { } else { AV1E_SET_TILE_COLUMNS }; - // Failed on android - call_ctl!(ctx, tile_set, (cfg.g_threads as f64 * 1.0f64).log2().ceil()); + call_ctl!(ctx, tile_set, tile_log2(cfg.g_threads)); call_ctl!(ctx, AV1E_SET_ROW_MT, 1); call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0); call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0); @@ -197,6 +200,23 @@ mod webrtc { Ok(()) } + + #[cfg(test)] + mod tests { + use super::*; + use std::os::raw::c_uint; + + #[test] + fn tile_log2_uses_c_uint_and_rounds_up() { + let one_thread: c_uint = tile_log2(1); + let three_threads: c_uint = tile_log2(3); + let max_threads: c_uint = tile_log2(64); + + assert_eq!(one_thread, 0); + assert_eq!(three_threads, 2); + assert_eq!(max_threads, 6); + } + } } impl EncoderApi for AomEncoder { diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs new file mode 100644 index 00000000000..3d19c6c419e --- /dev/null +++ b/libs/scrap/src/common/drm_reader.rs @@ -0,0 +1,477 @@ +// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here. + +use super::drmtap_dl::{ + self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display, + drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib, +}; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; + +// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side. +// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry. +pub(crate) const MAX_DIM: u32 = 16384; +// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin. +pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; +// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`. +pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24' +pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24' +pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24' +pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24' + +/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape. +pub const HIDDEN_CURSOR_ID: u64 = u64::MAX; + +pub struct CursorSnapshot { + pub id: u64, + pub width: u32, + pub height: u32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +/// One enumerated DRM display, physical geometry only (the server overlays the Wayland logical origin/scale where it can match one). +pub struct DisplaySnapshot { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, +} + +pub struct DrmDevice { + pub path: String, + /// Render node, or empty if this device has none. + pub render_node: String, + pub display_count: u32, +} + +/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a +/// field libdrmtap failed to terminate cannot read past it. +fn cstr_field(buf: &[std::os::raw::c_char]) -> String { + // SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`. + let bytes: &[u8] = + unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) }; + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found. +pub fn list_devices() -> Option> { + let lib = drmtap_dl::get()?; + let f = lib.list_devices?; + const MAX: usize = 16; + let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() }; + // SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count. + let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) }; + if n < 0 { + log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect"); + return None; + } + let n = (n as usize).min(MAX); + Some( + raw[..n] + .iter() + .map(|d| DrmDevice { + path: cstr_field(&d.path), + render_node: cstr_field(&d.render_node), + display_count: d.display_count, + }) + .collect(), + ) +} + +/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`. +/// Callers must open the value returned: opening the original re-resolves every symlink component after the check. +pub(super) fn device_under_dev_dri(path: &str) -> Option { + let p = std::fs::canonicalize(path).ok()?; + if p.parent() == Some(std::path::Path::new("/dev/dri")) { + Some(p) + } else { + None + } +} + +/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread). +pub struct DrmReader { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, + buf: Vec, +} + +impl DrmReader { + /// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC. + pub fn open(device: Option<&str>, crtc_id: u32) -> Option { + let lib = drmtap_dl::get()?; + let device_cstr = match device { + None => None, + Some(d) => { + let Some(canonical) = device_under_dev_dri(d) else { + log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open"); + return None; + }; + match canonical.to_str().and_then(|s| CString::new(s).ok()) { + Some(c) => Some(c), + None => return None, + } + } + }; + let cfg = drmtap_config { + device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + crtc_id, + helper_path: std::ptr::null(), + debug: 0, + }; + // SAFETY: cfg is a valid struct; device_cstr outlives this call. + let ctx = unsafe { (lib.open)(&cfg) }; + drop(device_cstr); + if ctx.is_null() { + log::info!("drmtap_open failed; DRM capture unavailable"); + return None; + } + Some(DrmReader { + lib, + ctx, + buf: Vec::new(), + }) + } + + /// Grab one frame, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab. + pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> { + // SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing + // `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free. + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = (self.lib.grab_mapped)(self.ctx, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_mapped failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + log::warn!( + "DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + // XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it). + if frame.format != 0 + && frame.format != DRM_FORMAT_XRGB8888 + && frame.format != DRM_FORMAT_ARGB8888 + { + log::warn!( + "DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + let (w, h) = (w as usize, h as usize); + let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + log::warn!( + "DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout frame too large", + )); + } + }; + // Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow. + match stride.checked_mul(h) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {} + other => { + log::warn!( + "DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout stride out of range", + )); + } + } + if self.buf.len() != frame_size { + self.buf.resize(frame_size, 0); + } + let src = frame.data as *const u8; + let dst = self.buf.as_mut_ptr(); + if stride == w * 4 { + std::ptr::copy_nonoverlapping(src, dst, frame_size); + } else { + for y in 0..h { + std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4); + } + } + (self.lib.frame_release)(self.ctx, &mut frame); + Ok((&self.buf, w, h)) + } + } + + /// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout: + /// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only. + pub fn render_node(&mut self) -> Option { + let f = self.lib.render_node?; + // SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed. + let ptr = unsafe { f(self.ctx) }; + if ptr.is_null() { + return None; + } + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .ok() + .map(|s| s.to_owned()) + } + + /// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this + /// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file + /// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY. + pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> { + let grab_desc = self.lib.grab_desc; + // SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL` + // libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself. + unsafe { + let mut desc: drmtap_dmabuf_desc = std::mem::zeroed(); + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = grab_desc(self.ctx, &mut desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + if errno == hbb_common::libc::ENOTSUP { + // A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild. + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "drmtap_grab_desc: no transferable dma-buf (ENOTSUP)", + )); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_desc failed: errno {errno}"), + )); + } + // `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy. + let raw_fd = if desc.dma_buf_fd >= 0 { + desc.dma_buf_fd + } else { + frame.dma_buf_fd + }; + if raw_fd < 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = desc.width; + let h = desc.height; + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout geometry {w}x{h} out of range"), + )); + } + // No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes), + )); + } + for p in 0..(planes as usize) { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + // dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf + // keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere. + let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0); + if dup_fd < 0 { + let e = io::Error::last_os_error(); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(e); + } + let owned = OwnedFd::from_raw_fd(dup_fd); + (self.lib.frame_release)(self.ctx, &mut frame); + desc.num_planes = planes; + desc.dma_buf_fd = -1; + Ok((owned, desc)) + } + } + + /// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails. + pub fn cursor(&mut self) -> Option { + // SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release. + unsafe { + let mut c: drmtap_cursor_info = std::mem::zeroed(); + let cret = (self.lib.get_cursor)(self.ctx, &mut c); + if cret != 0 { + return None; + } + let out = if c.visible == 0 { + Some(CursorSnapshot { + id: HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0, 0, 0, 0], + }) + } else if !c.pixels.is_null() + && c.width > 0 + && c.height > 0 + && (c.width as i64) * (c.height as i64) <= 256 * 256 + { + let cw = c.width as i32; + let ch = c.height as i32; + let n = (cw * ch) as usize; + let src = std::slice::from_raw_parts(c.pixels, n); + let mut hash: u64 = 1469598103934665603; + let mut colors = Vec::with_capacity(n * 4); + let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32); + for (i, &p) in src.iter().enumerate() { + let a = ((p >> 24) & 0xff) as u8; + let r = ((p >> 16) & 0xff) as u8; + let g = ((p >> 8) & 0xff) as u8; + let b = (p & 0xff) as u8; + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(a); + hash ^= p as u64; + hash = hash.wrapping_mul(1099511628211); + if a >= 128 { + let x = (i as i32) % cw; + let y = (i as i32) / cw; + if x < minx { minx = x; } + if x > maxx { maxx = x; } + if y < miny { miny = y; } + if y > maxy { maxy = y; } + } + } + let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 { + (c.hot_x, c.hot_y) + } else if maxx >= minx && maxy >= miny { + let (bw, bh) = (maxx - minx + 1, maxy - miny + 1); + if bh > bw * 2 { + ((minx + maxx) / 2, (miny + maxy) / 2) + } else { + (minx, miny) + } + } else { + (0, 0) + }; + // Fold geometry + hotspot into the id: identical pixels with a changed size or + // hotspot must count as a new shape, otherwise drm_capture_worker suppresses the + // update (it dedupes by id) and the client keeps rendering the stale cursor. + let mut id = hash; + for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] { + id ^= v; + id = id.wrapping_mul(1099511628211); + } + Some(CursorSnapshot { + id, + width: cw as u32, + height: ch as u32, + hotx, + hoty, + colors, + }) + } else { + None + }; + (self.lib.cursor_release)(self.ctx, &mut c); + out + } + } + + pub fn displays(&mut self) -> Vec { + // SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is clamped to the buffer before indexing. + unsafe { + let mut raw = vec![std::mem::zeroed::(); 16]; + let cap = raw.len() as i32; + let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap); + if n <= 0 { + return Vec::new(); + } + let count = (n as usize).min(raw.len()); + (0..count) + .map(|i| { + let name_bytes: Vec = raw[i] + .name + .iter() + .take_while(|&&ch| ch != 0) + .map(|&ch| ch as u8) + .collect(); + DisplaySnapshot { + name: String::from_utf8_lossy(&name_bytes).to_string(), + crtc_id: raw[i].crtc_id, + x: raw[i].x as i32, + y: raw[i].y as i32, + width: raw[i].width, + height: raw[i].height, + active: raw[i].active != 0, + } + }) + .collect() + } + } +} + +impl Drop for DrmReader { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open and is non-null. + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs new file mode 100644 index 00000000000..6df6ea61d2d --- /dev/null +++ b/libs/scrap/src/common/drm_render.rs @@ -0,0 +1,184 @@ +// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout +// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd +// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to +// its own CPU-mapped grab (`drmtap_grab_mapped`). + +use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; +use super::Pixfmt; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::RawFd; + +use super::drm_reader::{ + DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888, + MAX_DIM, MAX_FRAME_BYTES, +}; + +/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context +/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread. +pub struct RenderConverter { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, +} + +impl RenderConverter { + /// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection. + pub fn open_render(node: Option<&str>) -> Option { + let lib = drmtap_dl::get()?; + let open_render = lib.open_render; + let node_cstr = match node.filter(|n| !n.is_empty()) { + None => None, + // Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check. + Some(n) => match super::drm_reader::device_under_dev_dri(n) { + None => { + log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting"); + None + } + Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()), + }, + }; + // SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection. + let ctx = unsafe { + open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr())) + }; + if ctx.is_null() { + log::info!( + "drmtap_open_render({}) failed; no usable DRM render node", + node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}")) + ); + return None; + } + match node_cstr { + Some(c) => log::info!( + "drm: opened unprivileged convert context on the exporting GPU ({c:?})" + ), + None => log::info!( + "drm: opened unprivileged render-node convert context (auto-selected)" + ), + } + Some(RenderConverter { lib, ctx }) + } + + /// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`. + pub fn convert( + &mut self, + desc: &mut drmtap_dmabuf_desc, + received_fd: RawFd, + ) -> io::Result<(&[u8], u32, u32, Pixfmt)> { + { + let (w, h) = (desc.width, desc.height); + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"), + )); + } + // Reject, do not clamp, and write the normalized count back so the C reads the count bounded here. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing a dma-buf descriptor with num_planes {} (1..=4)", + desc.num_planes + ), + )); + } + desc.num_planes = planes; + let planes = planes as usize; + for p in 0..planes { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + } + let convert_dmabuf = self.lib.convert_dmabuf; + // LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`. + desc.dma_buf_fd = received_fd; + // SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed + // before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h). + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + "drmtap_convert_dmabuf produced an empty frame", + )); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}", + frame.format + ), + )); + } + let len = match stride.checked_mul(h as usize) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"), + )); + } + }; + let pixfmt = match frame.format { + DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA, + DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA, + // Unset by an older convert -> libdrmtap's normalized BGRA. + 0 => Pixfmt::BGRA, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"), + )); + } + }; + let data = std::slice::from_raw_parts(frame.data as *const u8, len); + Ok((data, w, h, pixfmt)) + } + } +} + +impl Drop for RenderConverter { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps + // this drop on the thread that created and used it (thread-local EGL + cached imports). + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs new file mode 100644 index 00000000000..0312c75bbce --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,421 @@ +// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency. + +use hbb_common::{libloading::Library, log}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::OnceLock; + +// C ABI structs: must match libdrmtap include/drmtap.h. + +#[repr(C)] +pub struct drmtap_ctx { + _private: [u8; 0], +} + +#[repr(C)] +pub struct drmtap_config { + pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card* + pub crtc_id: u32, // 0 = auto-select first active CRTC + pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN) + pub debug: c_int, +} + +impl Default for drmtap_config { + fn default() -> Self { + Self { + device_path: std::ptr::null(), + crtc_id: 0, + helper_path: std::ptr::null(), + debug: 0, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_display { + pub crtc_id: u32, + pub connector_id: u32, + pub name: [c_char; 32], + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + pub active: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_device { + pub path: [c_char; 64], + pub render_node: [c_char; 64], + pub driver: [c_char; 32], + pub display_count: u32, +} + +#[repr(C)] +pub struct drmtap_frame_info { + pub data: *mut c_void, + pub dma_buf_fd: c_int, + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: u32, + pub modifier: u64, + pub fb_id: u32, + pub _priv: *mut c_void, +} + +// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via +// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS. +// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_dmabuf_desc { + pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc of the scanout + pub modifier: u64, // DRM format modifier (tiling/compression) + pub fb_id: u32, // import-once cache key; 0 disables caching + pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1 + pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color) + pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride + pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3) + pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown +} + +impl Default for drmtap_dmabuf_desc { + fn default() -> Self { + Self { + dma_buf_fd: -1, + width: 0, + height: 0, + format: 0, + modifier: 0, + fb_id: 0, + num_planes: 0, + offsets: [0; 4], + pitches: [0; 4], + hdr_eotf: 0, + hdr_max_nits: 0, + } + } +} + +#[repr(C)] +pub struct drmtap_cursor_info { + pub x: i32, + pub y: i32, + pub hot_x: i32, + pub hot_y: i32, + pub width: u32, + pub height: u32, + pub pixels: *mut u32, + pub visible: c_int, + pub _priv: *mut c_void, +} + +// Resolved symbol typedefs. + +type FnVersion = unsafe extern "C" fn() -> c_int; +type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx; +type FnClose = unsafe extern "C" fn(*mut drmtap_ctx); +type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int; +type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int; +type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int; +type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info); +type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int; +type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info); +// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged +// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side. +type FnGrabDesc = + unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; +type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx; +// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none. +type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char; +type FnConvertDmabuf = + unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; + +/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid. +pub struct DrmtapLib { + _lib: Library, + pub open: FnOpen, + pub close: FnClose, + pub list_displays: FnListDisplays, + pub list_devices: Option, + pub grab_mapped: FnGrabMapped, + pub frame_release: FnFrameRelease, + pub get_cursor: FnGetCursor, + pub cursor_release: FnCursorRelease, + pub grab_desc: FnGrabDesc, + pub open_render: FnOpenRender, + pub convert_dmabuf: FnConvertDmabuf, + pub render_node: Option, + pub version: (c_int, c_int, c_int), +} + +// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability; +// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out. +unsafe impl Send for DrmtapLib {} +unsafe impl Sync for DrmtapLib {} + +const DRMTAP_ABI_MAJOR: c_int = 0; + +// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read +// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API +// has been present since 0.4.10. +const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0); + +// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes +// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets. +const DRMTAP_ABI_MINOR: c_int = 5; + +/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor). +fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool { + major == DRMTAP_ABI_MAJOR + && minor == DRMTAP_ABI_MINOR + && (minor, patch) >= DRMTAP_MIN_MINOR_PATCH +} + +impl DrmtapLib { + fn load() -> Option { + // Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so. + const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0"; + // Bare sonames exist so an unpackaged development build can load a locally built .so from + // the normal ld.so search path. They are NOT offered when running as root: this is the one + // place where which file happens to be on the load path decides what gets mapped into the + // CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first + // anyway. A root process that reaches the fallback has no bundled library, which is the + // PipeWire-fallback case, not a reason to search. + const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"]; + let is_root = unsafe { hbb_common::libc::geteuid() } == 0; + let candidates: Vec<&str> = if is_root { + vec![INSTALLED] + } else { + std::iter::once(INSTALLED).chain(DEV_ONLY).collect() + }; + unsafe { + let mut errs = Vec::new(); + let found = candidates.iter().find_map(|n| match Library::new(*n) { + Ok(l) => Some((l, *n)), + Err(e) => { + errs.push(format!("{n}: {e}")); + None + } + }); + let Some((lib, name)) = found else { + // The dlerror names the real cause (a missing soname, a glibc too old for the + // bundled build); the caller only reports that DRM capture is off. + log::warn!("libdrmtap dlopen failed: {}", errs.join("; ")); + return None; + }; + // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare + // soname, while `canonicalize` resolves a relative name against it. + let real = std::path::Path::new(name) + .is_absolute() + .then(|| std::fs::canonicalize(name).ok()) + .flatten(); + let version: FnVersion = *lib.get(b"drmtap_version").ok()?; + let v = version(); + let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + if !abi_accepted(major, minor, patch) { + let why = if major != DRMTAP_ABI_MAJOR { + "the struct layouts this build mirrors track the ABI major, so reading a \ + frame descriptor through a mismatched one would mis-decode it" + } else if minor != DRMTAP_ABI_MINOR { + "this build mirrors the struct layouts of one minor and only that one; \ + under 0.x semver the minor is the breaking axis, so an unverified minor \ + could be read at the wrong offsets. Widening it is a deliberate act, done \ + with the layouts re-checked field by field" + } else { + "it predates the split-capture API, so its only capture path converts \ + in-process, which in the root service means loading the GL stack there" + }; + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \ + use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \ + v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \ + falling back to PipeWire/portal." + ); + return None; + } + let open: FnOpen = *lib.get(b"drmtap_open").ok()?; + let close: FnClose = *lib.get(b"drmtap_close").ok()?; + let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?; + let list_devices: Option = + lib.get(b"drmtap_list_devices").ok().map(|s| *s); + let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?; + let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?; + let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?; + let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?; + let grab: Option = lib.get(b"drmtap_grab_desc").ok().map(|s| *s); + let open_r: Option = lib.get(b"drmtap_open_render").ok().map(|s| *s); + let conv: Option = + lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s); + let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) { + (Some(g), Some(o), Some(c)) => (g, o, c), + (grab, open_r, conv) => { + let mut missing = Vec::new(); + if grab.is_none() { + missing.push("drmtap_grab_desc"); + } + if open_r.is_none() { + missing.push("drmtap_open_render"); + } + if conv.is_none() { + missing.push("drmtap_convert_dmabuf"); + } + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \ + {}: it is a stale or pre-release build, not the version it claims. \ + Refusing to load; falling back to PipeWire/portal.", + missing.join(", ") + ); + return None; + } + }; + let render_node: Option = + lib.get(b"drmtap_render_node").ok().map(|s| *s); + // Log the load only now that every required symbol resolved: this fn still returns None on a missing one. + let loaded_from = real + .as_ref() + .map_or_else(|| name.to_owned(), |p| p.display().to_string()); + if loaded_from == name { + log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})"); + } else { + log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})"); + } + let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none()); + if (minor, patch) >= (4, 15) && (no_node || no_devices) { + let missing = if no_node && no_devices { + "drmtap_render_node and drmtap_list_devices" + } else if no_node { + "drmtap_render_node" + } else { + "drmtap_list_devices" + }; + let effect = if no_node && no_devices { + "Multi-GPU display enumeration and exporting-GPU selection stay disabled." + } else if no_node { + "Exporting-GPU selection stays disabled." + } else { + "Multi-GPU display enumeration stays disabled." + }; + log::warn!( + "libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \ + {missing}: it is a stale or pre-release build. Check what the soname symlink \ + points at and remove any leftover libdrmtap.so.0* beside it. {effect}" + ); + } + Some(DrmtapLib { + _lib: lib, + open, + close, + list_displays, + list_devices, + grab_mapped, + frame_release, + get_cursor, + cursor_release, + grab_desc, + open_render, + convert_dmabuf, + render_node, + version: (major, minor, patch), + }) + } + } +} + +static DRMTAP_LIB: OnceLock> = OnceLock::new(); + +/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered. +pub fn get() -> Option<&'static DrmtapLib> { + DRMTAP_LIB + .get_or_init(|| { + let lib = DrmtapLib::load(); + if lib.is_none() { + log::info!("libdrmtap not available or not usable; DRM capture disabled"); + } + lib + }) + .as_ref() +} + +#[cfg(test)] +mod tests { + use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH}; + + #[test] + fn abi_gate_rejects_a_library_from_before_the_split() { + // These are refused because their MINOR differs from the verified one, which is the only + // reason the gate needs. Naming the pre-split releases keeps the intent readable, but do + // not read this as the floor doing the work: see the test below. + for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is not the verified minor and must be refused" + ); + } + } + + #[test] + fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() { + // With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the + // minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is + // the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10) + // did for the split API. This test exists so nobody reads the pre-split test above as + // evidence that the floor is live -- if that ever matters, this assert is the tripwire. + let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH; + assert_eq!( + floor_minor, DRMTAP_ABI_MINOR, + "the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable" + ); + if floor_patch == 0 { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0), + "patch 0 of the verified minor must be accepted while the floor is 0" + ); + } else { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1)); + } + } + + #[test] + fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() { + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch)); + for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is a patch of the verified minor and must be accepted" + ); + } + } + + #[test] + fn abi_gate_rejects_an_unknown_newer_minor() { + // Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the + // NEW verified minor must be refused -- which is what a hardcoded list did before. + let verified = DRMTAP_ABI_MINOR; + for (minor, patch) in [ + (verified - 1, 99), + (verified + 1, 0), + (verified + 1, 99), + (verified + 4, 9), + ] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is an unverified minor and must be refused" + ); + } + } + + #[test] + fn abi_gate_rejects_another_major_in_both_directions() { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0)); + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99)); + } +} diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0dd5..1efed11769b 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,6 +16,12 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drmtap_dl; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_reader; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_render; pub use self::linux::*; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; diff --git a/libs/scrap/src/common/record.rs b/libs/scrap/src/common/record.rs index d121984f1be..ffeb25791bb 100644 --- a/libs/scrap/src/common/record.rs +++ b/libs/scrap/src/common/record.rs @@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer}; const MIN_SECS: u64 = 1; +// Replace characters that are invalid in Windows filename components so recordings remain portable. +// Control characters are also replaced because they can make filenames invalid +// on Windows or invisible and difficult to handle on Linux and macOS. +fn sanitize_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { + '_' + } else { + c + } + }) + .collect() +} + #[derive(Debug, Clone)] pub struct RecorderContext { pub server: bool, @@ -45,7 +61,7 @@ impl RecorderContext2 { } let file = if ctx.server { "incoming" } else { "outgoing" }.to_string() + "_" - + &ctx.id.clone() + + &sanitize_filename_component(&ctx.id) + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string() + &format!( "{}{}_", @@ -421,3 +437,24 @@ impl Drop for HwRecorder { self.ctx.tx.as_ref().map(|tx| tx.send(state)); } } + +#[cfg(test)] +mod tests { + use super::sanitize_filename_component; + + #[test] + fn sanitize_recording_filename_component() { + assert_eq!( + sanitize_filename_component("192.168.1.2:21118"), + "192.168.1.2_21118" + ); + assert_eq!( + sanitize_filename_component("[2001:db8::1]:21118"), + "[2001_db8__1]_21118" + ); + assert_eq!( + sanitize_filename_component("peer/name\\with?bad\nchars"), + "peer_name_with_bad_chars" + ); + } +} diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index a5c937491b8..bed90fd7673 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -14,6 +14,9 @@ lazy_static! { static ref DISPLAYS: Mutex>> = Mutex::new(None); } +static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); pub struct Displays { @@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() { // Return (min_x, max_x, min_y, max_y) pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { let wayland_displays = get_displays(); - let displays = &wayland_displays.displays; + desktop_rect_of(&wayland_displays.displays) +} + +// The desktop rect and per-display logical rects, always read live from the +// compositor in a single roundtrip. Skips the displays cache and the primary-monitor +// detection (which may spawn external commands), so it is cheap enough to poll for +// layout changes. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec)> { + match get_wayland_displays() { + Ok(displays) => { + desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays))) + } + Err(err) => { + warn!("Failed to get wayland displays: {}", err); + None + } + } +} + +fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> { if displays.is_empty() { return None; } @@ -243,10 +265,13 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { // This may occur if the Wayland compositor does not provide logical size information, // or if display information is incomplete. We fall back to physical size, which provides // usable dimensions, but may not always be correct depending on compositor behavior. - warn!( + // Warn only once, the live path polls this while a session is active. + if !MISSING_LOGICAL_SIZE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + warn!( "Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).", d.x, d.y, d.width, d.height ); + } (d.width, d.height) }; max_x = max_x.max(d.x + size.0); @@ -254,3 +279,289 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { } Some((min_x, max_x, min_y, max_y)) } + +/// One display's logical rectangle in the desktop coordinate space the client uses: +/// logical origin plus logical size, falling back to physical size when the compositor +/// reports no logical size (matching `desktop_rect_of`). +#[derive(Clone, Debug, PartialEq)] +pub struct DisplayRect { + pub name: String, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { + // Match `desktop_rect_of`: a single display uses its physical size (its scale is + // reported as 1.0 to the client), multiple displays use logical size. This keeps a + // single display a no-op for the remap (its origin never shifts) and keeps the rects + // in the same coordinate space the client's coordinates are expressed in. + let single = displays.len() == 1; + displays + .iter() + .map(|d| { + let (w, h) = if single { + (d.width, d.height) + } else { + d.logical_size.unwrap_or((d.width, d.height)) + }; + DisplayRect { + name: d.name.clone(), + x: d.x, + y: d.y, + w, + h, + } + }) + .collect() +} + +// Per-display logical rects from the cached init snapshot. The client's injected +// coordinates are `local + origin` in this layout, so it is the baseline to map from. +pub fn get_display_rects_for_uinput() -> Vec { + logical_rects_of(&get_displays().displays) +} + +/// Remap an injected coordinate from the layout the client still believes in +/// (`baseline`, captured at session init) to the current compositor layout (`live`). +/// +/// A single-display client sends whole-desktop coordinates: `local + baseline_origin[d]` +/// for whichever display `d` it is following. If that display's origin or logical size +/// has since changed (e.g. another monitor was rescaled, shifting this one), the +/// coordinate lands offset. We find the baseline display the point falls in, then map +/// the point into the same display's live rectangle, matched by connector name (or, when +/// the compositor reports no names, by index while the display count is unchanged). +/// +/// Returns the input unchanged when the point is outside every baseline display or the +/// matched display is gone, so a failed match never moves the cursor further off than +/// leaving it alone. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn remap_to_live_layout( + x: i32, + y: i32, + baseline: &[DisplayRect], + live: &[DisplayRect], +) -> (i32, i32) { + let Some((bi, b)) = baseline + .iter() + .enumerate() + .find(|(_, r)| x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) + else { + return (x, y); + }; + let matched = if b.name.is_empty() { + // Nameless compositor: index-match, but only while the count is unchanged. A + // named display that is simply gone from the live layout must fall through to + // "unchanged" below, not get index-matched to whatever now sits at its index. + if baseline.len() == live.len() { + live.get(bi) + } else { + None + } + } else { + live.iter().find(|r| r.name == b.name) + }; + let Some(l) = matched else { + return (x, y); + }; + // Map the point into the live rectangle, preserving position within the display so a + // scale change on the followed display itself is corrected too, not only a shift. + // Scale by (extent - 1) so both endpoints land exactly: the client clamps its + // coordinate to `[origin, origin + w - 1]`, and mapping that span to the live span's + // `[0, w' - 1]` keeps the far edge reachable (hot corners) in both directions, and + // stays an exact shift when the size is unchanged. + let nx = map_axis(x, b.x, b.w, l.x, l.w); + let ny = map_axis(y, b.y, b.h, l.y, l.h); + (nx, ny) +} + +fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_extent: i32) -> i32 { + if base_extent <= 1 || live_extent <= 1 { + return live_origin; + } + live_origin + ((v - base_origin) as i64 * (live_extent - 1) as i64 / (base_extent - 1) as i64) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn display( + x: i32, + y: i32, + width: i32, + height: i32, + logical_size: Option<(i32, i32)>, + ) -> WaylandDisplayInfo { + WaylandDisplayInfo { + name: "".to_owned(), + x, + y, + width, + height, + logical_size, + refresh_rate: 60, + } + } + + #[test] + fn test_desktop_rect_empty() { + assert_eq!(desktop_rect_of(&[]), None); + } + + #[test] + fn test_desktop_rect_single_display_uses_physical_size() { + let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))]; + assert_eq!(desktop_rect_of(&displays), Some((0, 2880, 0, 1800))); + } + + #[test] + fn test_desktop_rect_multi_display_uses_logical_size() { + // Laptop panel at 155% below two stacked externals at 100%. + let displays = [ + display(0, 718, 2880, 1800, Some((1859, 1162))), + display(1859, 0, 1920, 1080, Some((1920, 1080))), + display(1859, 1080, 1920, 1080, Some((1920, 1080))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160))); + } + + #[test] + fn test_desktop_rect_missing_logical_size_falls_back_to_physical() { + let displays = [ + display(0, 0, 2560, 1440, None), + display(2560, 0, 2560, 1440, Some((2560, 1440))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440))); + } + + fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect { + DisplayRect { + name: name.to_owned(), + x, + y, + w, + h, + } + } + + // The reported failure: connect to the second display, rescale the primary. + // Baseline: two 2560-wide displays side by side, both at 100%. + // Live: the primary (DP-1) rescaled to 125% -> 2048 logical wide, so the second + // display (DP-2) shifts left from x=2560 to x=2048. A client following DP-2 keeps + // sending coordinates offset by DP-2's old origin (2560). + #[test] + fn test_remap_primary_rescale_shifts_second_display() { + let baseline = [ + rect("DP-1", 0, 0, 2560, 1440), + rect("DP-2", 2560, 0, 2560, 1440), + ]; + let live = [ + rect("DP-1", 0, 0, 2048, 1440), + rect("DP-2", 2048, 0, 2560, 1440), + ]; + // Top-left of DP-2: client sends (2560, 0), should land at live DP-2 origin. + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + // Middle of DP-2 keeps its fractional position. + assert_eq!( + remap_to_live_layout(3840, 720, &baseline, &live), + (3328, 720) + ); + } + + // A point on the rescaled display itself is squeezed to its new logical width. + #[test] + fn test_remap_scales_within_resized_display() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)]; + // x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide + // live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint). + assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500)); + } + + // The far edge of the followed display stays reachable when it is enlarged, so hot + // corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the + // client's last column (2047) must map to the live last column (2559), not 2558. + #[test] + fn test_remap_enlarged_display_reaches_far_edge() { + let baseline = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 1920, 1080)]; + let live = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2047, 0, &baseline, &live), (2559, 0)); + assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0)); + } + + // No drift: identical layouts map every point to itself. + #[test] + fn test_remap_identity_when_unchanged() { + let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700)); + } + + // Point outside every baseline display is left untouched. + #[test] + fn test_remap_point_outside_all_displays_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000)); + } + + // Matched display gone from the live layout (e.g. unplugged): leave the point be + // rather than mapping it somewhere wrong. + #[test] + fn test_remap_display_removed_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // Nameless compositor: fall back to index matching while the count is unchanged. + #[test] + fn test_remap_nameless_index_fallback() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + } + + // Nameless compositor with a changed count: cannot index-match safely, so no-op. + #[test] + fn test_remap_nameless_count_changed_unchanged() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0)); + } + + // A named display absent from the live layout, but the count is unchanged (e.g. a + // monitor was swapped for a different one at the same index): the index fallback is + // for nameless layouts only, so a named miss stays unchanged rather than mapping to + // whatever now occupies that index. + #[test] + fn test_remap_named_miss_equal_count_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // A single display uses physical size in both baseline and live (scale reported as + // 1.0), so it never drifts and the remap is a no-op even across a rescale. + #[test] + fn test_logical_rects_single_display_uses_physical() { + let displays = [display(0, 0, 2560, 1440, Some((2048, 1152)))]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2560, 1440)] + ); + } + + // Multiple displays use logical size, falling back to physical when absent. + #[test] + fn test_logical_rects_multi_display_uses_logical() { + let displays = [ + display(0, 0, 2560, 1440, Some((2048, 1152))), + display(2048, 0, 1920, 1080, None), + ]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)] + ); + } +} diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index 8859d0d3b99..f0852e56441 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -507,6 +507,22 @@ where }) } +// The request object path a portal method call will use, derived from our unique +// bus name and the `handle_token` we pass in the call arguments. Knowing it up +// front lets us subscribe to the `Response` signal *before* making the call. +// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html +fn get_request_path( + conn: &SyncConnection, + handle_token: &str, +) -> Result, dbus::Error> { + let sender = conn.unique_name().trim_start_matches(':').replace('.', "_"); + dbus::Path::new(format!( + "/org/freedesktop/portal/desktop/request/{}/{}", + sender, handle_token + )) + .map_err(|_| dbus::Error::new_failed("Failed to construct portal request path")) +} + pub fn get_portal(conn: &SyncConnection) -> Proxy<&SyncConnection> { conn.with_proxy( "org.freedesktop.portal.Desktop", @@ -632,13 +648,14 @@ pub fn request_remote_desktop( let failure_res = failure.clone(); let session: Arc>> = Arc::new(Mutex::new(None)); let session_res = session.clone(); + let create_session_handle_token = "u1"; args.insert( "session_handle_token".to_string(), - Variant(Box::new("u1".to_string())), + Variant(Box::new(create_session_handle_token.to_string())), ); args.insert( "handle_token".to_string(), - Variant(Box::new("u1".to_string())), + Variant(Box::new(create_session_handle_token.to_string())), ); let mut is_support_restore_token = false; @@ -654,15 +671,9 @@ pub fn request_remote_desktop( // between the caller subscribing to the signal after receiving the reply for the method call and the signal getting emitted, // a convention for Request object paths has been established that allows // the caller to subscribe to the signal before making the method call. - let path; - if is_server_running() { - path = screencast_portal::create_session(&portal, args)?; - } else { - path = remote_desktop_portal::create_session(&portal, args)?; - } handle_response( &conn, - path, + get_request_path(&conn, create_session_handle_token)?, on_create_session_response( fd.clone(), streams.clone(), @@ -673,6 +684,11 @@ pub fn request_remote_desktop( ), failure_res.clone(), )?; + if is_server_running() { + let _ = screencast_portal::create_session(&portal, args)?; + } else { + let _ = remote_desktop_portal::create_session(&portal, args)?; + } // wait 3 minutes for user interaction for _ in 0..1800 { @@ -751,9 +767,10 @@ fn on_create_session_response( // persist_mode may be configured by the user. args.insert("persist_mode".to_string(), Variant(Box::new(2u32))); } + let select_sources_handle_token = "u3"; args.insert( "handle_token".to_string(), - Variant(Box::new("u3".to_string())), + Variant(Box::new(select_sources_handle_token.to_string())), ); // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html if is_server_running() { @@ -769,42 +786,43 @@ fn on_create_session_response( }); } - let path = portal.select_sources(ses.clone(), args)?; handle_response( c, - path, + get_request_path(c, select_sources_handle_token)?, on_select_sources_response( fd.clone(), streams.clone(), failure.clone(), - ses, + ses.clone(), is_support_restore_token, ), failure.clone(), )?; + let _ = portal.select_sources(ses.clone(), args)?; } else { // TODO: support persist_mode for remote_desktop_portal // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html + let select_devices_handle_token = "u2"; args.insert( "handle_token".to_string(), - Variant(Box::new("u2".to_string())), + Variant(Box::new(select_devices_handle_token.to_string())), ); args.insert("types".to_string(), Variant(Box::new(7u32))); - let path = portal.select_devices(ses.clone(), args)?; handle_response( c, - path, + get_request_path(c, select_devices_handle_token)?, on_select_devices_response( fd.clone(), streams.clone(), failure.clone(), - ses, + ses.clone(), is_support_restore_token, ), failure.clone(), )?; + let _ = portal.select_devices(ses.clone(), args)?; } Ok(()) @@ -825,9 +843,10 @@ fn on_select_devices_response( move |_: OrgFreedesktopPortalRequestResponse, c, _| { let portal = get_portal(c); let mut args: PropMap = HashMap::new(); + let select_sources_handle_token = "u3"; args.insert( "handle_token".to_string(), - Variant(Box::new("u3".to_string())), + Variant(Box::new(select_sources_handle_token.to_string())), ); // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html if is_server_running() { @@ -836,19 +855,19 @@ fn on_select_devices_response( args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32))); let session = session.clone(); - let path = portal.select_sources(session.clone(), args)?; handle_response( c, - path, + get_request_path(c, select_sources_handle_token)?, on_select_sources_response( fd.clone(), streams.clone(), failure.clone(), - session, + session.clone(), is_support_restore_token, ), failure.clone(), )?; + let _ = portal.select_sources(session.clone(), args)?; Ok(()) } @@ -868,19 +887,14 @@ fn on_select_sources_response( move |_: OrgFreedesktopPortalRequestResponse, c, _| { let portal = get_portal(c); let mut args: PropMap = HashMap::new(); + let start_handle_token = "u4"; args.insert( "handle_token".to_string(), - Variant(Box::new("u4".to_string())), + Variant(Box::new(start_handle_token.to_string())), ); - let path; - if is_server_running() { - path = screencast_portal::start(&portal, session.clone(), "", args)?; - } else { - path = remote_desktop_portal::start(&portal, session.clone(), "", args)?; - } handle_response( c, - path, + get_request_path(c, start_handle_token)?, on_start_response( fd.clone(), streams.clone(), @@ -889,6 +903,11 @@ fn on_select_sources_response( ), failure.clone(), )?; + if is_server_running() { + let _ = screencast_portal::start(&portal, session.clone(), "", args)?; + } else { + let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)?; + } Ok(()) } diff --git a/res/PKGBUILD b/res/PKGBUILD index 47eeefd4bf0..8f3cc9c81d7 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.4.8 +pkgver=1.4.9 pkgrel=0 epoch= pkgdesc="" diff --git a/res/msi/Package/Language/Package.en-us.wxl b/res/msi/Package/Language/Package.en-us.wxl index c65a5126d4a..74919e04dc6 100644 --- a/res/msi/Package/Language/Package.en-us.wxl +++ b/res/msi/Package/Language/Package.en-us.wxl @@ -21,8 +21,6 @@ This file contains the declaration of all the localizable strings. - - @@ -35,8 +33,6 @@ This file contains the declaration of all the localizable strings. - - diff --git a/res/msi/Package/License.rtf b/res/msi/Package/License.rtf index 4292be18fcd..45a51718a43 100644 --- a/res/msi/Package/License.rtf +++ b/res/msi/Package/License.rtf @@ -79,7 +79,7 @@ heading 1;}{\s2\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\li \ab\af1 \ltrch\fcs0 \b\ul\cf2\lang1033\langfe2052\langnp1033\insrsid1917520 \par }\pard \ltrpar\s2\qj \li0\ri0\sb100\sa100\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\lin0\itap0\pararsid8979511 {\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \hich\af1\dbch\af31505\loch\f1 \hich\f1 This Privacy Policy (hereinafter the \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 Policy}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 -\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 +\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Tech Pte. Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 us}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 or \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 we}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 @@ -300,4 +300,4 @@ b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae38a38f56e422a3a6 \lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5; \lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6; \lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention; -\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}} \ No newline at end of file +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}} diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index c590549f437..ff0b5f510fd 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -85,7 +85,7 @@ def make_parser(): "-m", "--manufacturer", type=str, - default="PURSLANE", + default="Purslane Tech Pte. Ltd.", help="The app manufacturer.", ) return parser @@ -499,7 +499,7 @@ def update_license_file(app_name): license_content = f.read() license_content = license_content.replace("website rustdesk.com and other ", "") license_content = license_content.replace("RustDesk", app_name) - license_content = re.sub("Purslane Ltd", app_name, license_content, flags=re.IGNORECASE) + license_content = re.sub(r"Purslane(?: Tech Pte\.)? Ltd", app_name, license_content, flags=re.IGNORECASE) with open(license_file, "w", encoding="utf-8") as f: f.write(license_content) diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index 9138449cb3f..ea7dd8a4059 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.8 +Version: 1.4.9 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index c34b8ae9650..272148d9190 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.8 +Version: 1.4.9 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm.spec b/res/rpm.spec index 5f5e6b2c6b1..8aaf2508c9e 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.8 +Version: 1.4.9 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/vcpkg-triplets/arm-neon-android.cmake b/res/vcpkg-triplets/arm-neon-android.cmake new file mode 100644 index 00000000000..e0a9c042548 --- /dev/null +++ b/res/vcpkg-triplets/arm-neon-android.cmake @@ -0,0 +1,7 @@ +set(VCPKG_TARGET_ARCHITECTURE arm) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Android) +set(VCPKG_CMAKE_SYSTEM_VERSION 21) +set(VCPKG_MAKE_BUILD_TRIPLET "--host=armv7a-linux-androideabi") +set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=armeabi-v7a -DANDROID_ARM_NEON=ON) diff --git a/res/vcpkg-triplets/arm64-android.cmake b/res/vcpkg-triplets/arm64-android.cmake new file mode 100644 index 00000000000..ffd358f9aed --- /dev/null +++ b/res/vcpkg-triplets/arm64-android.cmake @@ -0,0 +1,7 @@ +set(VCPKG_TARGET_ARCHITECTURE arm64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Android) +set(VCPKG_CMAKE_SYSTEM_VERSION 21) +set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-linux-android") +set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=arm64-v8a) diff --git a/res/vcpkg-triplets/x64-android.cmake b/res/vcpkg-triplets/x64-android.cmake new file mode 100644 index 00000000000..37ad1f6760e --- /dev/null +++ b/res/vcpkg-triplets/x64-android.cmake @@ -0,0 +1,7 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Android) +set(VCPKG_CMAKE_SYSTEM_VERSION 21) +set(VCPKG_MAKE_BUILD_TRIPLET "--host=x86_64-linux-android") +set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86_64) diff --git a/res/vcpkg-triplets/x86-android.cmake b/res/vcpkg-triplets/x86-android.cmake new file mode 100644 index 00000000000..992816cf1b1 --- /dev/null +++ b/res/vcpkg-triplets/x86-android.cmake @@ -0,0 +1,7 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_CMAKE_SYSTEM_NAME Android) +set(VCPKG_CMAKE_SYSTEM_VERSION 21) +set(VCPKG_MAKE_BUILD_TRIPLET "--host=i686-linux-android") +set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86) diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 2f3b3550f9f..00000000000 --- a/src/cli.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::client::*; -use async_trait::async_trait; -use hbb_common::{ - config::PeerConfig, - config::READ_TIMEOUT, - futures::{SinkExt, StreamExt}, - log, - message_proto::*, - protobuf::Message as _, - rendezvous_proto::ConnType, - tokio::{self, sync::mpsc}, - Stream, -}; -use std::sync::{Arc, RwLock}; - -#[derive(Clone)] -pub struct Session { - id: String, - lc: Arc>, - sender: mpsc::UnboundedSender, - password: String, -} - -impl Session { - pub fn new(id: &str, sender: mpsc::UnboundedSender) -> Self { - let mut password = "".to_owned(); - if PeerConfig::load(id).password.is_empty() { - match rpassword::prompt_password("Enter password: ") { - Ok(p) => password = p, - Err(e) => { - log::error!("Failed to read password: {:?}", e); - password = "".to_owned(); - } - } - } - let session = Self { - id: id.to_owned(), - sender, - password, - lc: Default::default(), - }; - session.lc.write().unwrap().initialize( - id.to_owned(), - ConnType::PORT_FORWARD, - None, - false, - None, - None, - ); - session - } -} - -#[async_trait] -impl Interface for Session { - fn get_login_config_handler(&self) -> Arc> { - return self.lc.clone(); - } - - fn msgbox(&self, msgtype: &str, title: &str, text: &str, link: &str) { - match msgtype { - "input-password" => { - self.sender - .send(Data::Login((self.password.clone(), true))) - .ok(); - } - "re-input-password" => { - log::error!("{}: {}", title, text); - match rpassword::prompt_password("Enter password: ") { - Ok(password) => { - let login_data = Data::Login((password, true)); - self.sender.send(login_data).ok(); - } - Err(e) => { - log::error!("reinput password failed, {:?}", e); - } - } - } - msg if msg.contains("error") => { - log::error!("{}: {}: {}", msgtype, title, text); - } - _ => { - log::info!("{}: {}: {}", msgtype, title, text); - } - } - } - - fn handle_login_error(&self, err: &str) -> bool { - handle_login_error(self.lc.clone(), err, self) - } - - fn handle_peer_info(&self, pi: PeerInfo) { - self.lc.write().unwrap().handle_peer_info(&pi); - } - - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) { - log::info!( - "password={}", - hbb_common::password_security::temporary_password() - ); - handle_hash(self.lc.clone(), &pass, hash, self, peer).await; - } - - async fn handle_login_from_ui( - &self, - os_username: String, - os_password: String, - password: String, - remember: bool, - peer: &mut Stream, - ) { - handle_login_from_ui( - self.lc.clone(), - os_username, - os_password, - password, - remember, - peer, - ) - .await; - } - - async fn handle_test_delay(&self, t: TestDelay, peer: &mut Stream) { - handle_test_delay(t, peer).await; - } - - fn send(&self, data: Data) { - self.sender.send(data).ok(); - } -} - -#[tokio::main(flavor = "current_thread")] -pub async fn connect_test(id: &str, key: String, token: String) { - let (sender, mut receiver) = mpsc::unbounded_channel::(); - let handler = Session::new(&id, sender); - match crate::client::Client::start(id, &key, &token, ConnType::PORT_FORWARD, handler).await { - Err(err) => { - log::error!("Failed to connect {}: {}", &id, err); - } - Ok((mut stream, direct)) => { - log::info!("direct: {}", direct); - // rpassword::prompt_password("Input anything to exit").ok(); - loop { - tokio::select! { - res = hbb_common::timeout(READ_TIMEOUT, stream.next()) => match res { - Err(_) => { - log::error!("Timeout"); - break; - } - Ok(Some(Ok(bytes))) => { - if let Ok(msg_in) = Message::parse_from_bytes(&bytes) { - match msg_in.union { - Some(message::Union::Hash(hash)) => { - log::info!("Got hash"); - break; - } - _ => {} - } - } - } - _ => {} - } - } - } - } - } -} - -#[tokio::main(flavor = "current_thread")] -pub async fn start_one_port_forward( - id: String, - port: i32, - remote_host: String, - remote_port: i32, - key: String, - token: String, -) { - crate::common::test_rendezvous_server(); - crate::common::test_nat_type(); - let (sender, mut receiver) = mpsc::unbounded_channel::(); - let handler = Session::new(&id, sender); - if let Err(err) = crate::port_forward::listen( - handler.id.clone(), - handler.password.clone(), - port, - handler.clone(), - receiver, - &key, - &token, - handler.lc.clone(), - remote_host, - remote_port, - ) - .await - { - log::error!("Failed to listen on {}: {}", port, err); - } - log::info!("port forward (:{}) exit", port); -} diff --git a/src/client.rs b/src/client.rs index 680ed1bec95..e1e4c803443 100644 --- a/src/client.rs +++ b/src/client.rs @@ -252,7 +252,7 @@ impl Client { (i32, String), bool, )> { - if config::is_incoming_only() { + if config::is_incoming_only() && !is_switch_sides_back(conn_type, &interface).await { bail!("Incoming only mode"); } // to-do: remember the port for each peer, so that we can retry easier @@ -426,8 +426,8 @@ impl Client { NatType::from_i32(my_nat_type).unwrap_or(NatType::UNKNOWN_NAT) }; - if !key.is_empty() && !token.is_empty() { - // mainly for the security of token + let switch_code = interface.get_switch_code(); + if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) { secure_tcp(&mut socket, &key) .await .map_err(|e| anyhow!("Failed to secure tcp: {}", e))?; @@ -469,6 +469,7 @@ impl Client { udp_port: udp_nat_port as _, force_relay: interface.is_force_relay(), socket_addr_v6: ipv6.1.unwrap_or_default(), + switch_code, ..Default::default() }); for i in 1..=3 { @@ -716,6 +717,7 @@ impl Client { let mut direct = !conn.is_err(); if interface.is_force_relay() || conn.is_err() { if !relay_server.is_empty() { + let switch_code = interface.get_switch_code(); conn = Self::request_relay( peer_id, relay_server.to_owned(), @@ -724,6 +726,7 @@ impl Client { key, token, conn_type, + &switch_code, ) .await; if let Err(e) = conn { @@ -844,6 +847,7 @@ impl Client { key: &str, token: &str, conn_type: ConnType, + switch_code: &str, ) -> ResultType { let mut succeed = false; let mut uuid = "".to_owned(); @@ -855,8 +859,7 @@ impl Client { .await .with_context(|| "Failed to connect to rendezvous server")?; - if !key.is_empty() && !token.is_empty() { - // mainly for the security of token + if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) { secure_tcp(&mut socket, key).await?; } @@ -877,6 +880,7 @@ impl Client { uuid: uuid.clone(), relay_server: relay_server.clone(), secure, + switch_code: switch_code.to_owned(), ..Default::default() }); socket.send(&msg_out).await?; @@ -1401,6 +1405,10 @@ impl AudioHandler { /// Handle audio format and create an audio decoder. pub fn handle_format(&mut self, f: AudioFormat) { + if !is_supported_audio_channel_count(f.channels) { + log::error!("Unsupported audio channel count: {}", f.channels); + return; + } match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) { Ok(d) => { let buffer = vec![0.; f.sample_rate as usize * f.channels as usize]; @@ -1540,6 +1548,23 @@ impl AudioHandler { } } +fn is_supported_audio_channel_count(channels: u32) -> bool { + (1..=2).contains(&channels) +} + +#[cfg(test)] +mod audio_format_tests { + use super::is_supported_audio_channel_count; + + #[test] + fn only_mono_and_stereo_are_supported() { + assert!(is_supported_audio_channel_count(1)); + assert!(is_supported_audio_channel_count(2)); + assert!(!is_supported_audio_channel_count(0)); + assert!(!is_supported_audio_channel_count(u32::MAX)); + } +} + /// Video handler for the [`Client`]. pub struct VideoHandler { decoder: Decoder, @@ -2650,9 +2675,6 @@ impl LoginConfigHandler { os_password: String, password: Vec, ) -> Message { - #[cfg(any(target_os = "android", target_os = "ios"))] - let my_id = Config::get_id_or(crate::DEVICE_ID.lock().unwrap().clone()); - #[cfg(not(any(target_os = "android", target_os = "ios")))] let my_id = Config::get_id(); let (my_id, pure_id) = if let Some((id, _, _)) = self.other_server.as_ref() { let server = Config::get_rendezvous_server(); @@ -3433,9 +3455,55 @@ pub fn handle_login_error( } } +// "Switch sides" requires the incoming-only client to connect back to its +// controlling peer; verify the local pending uuid before opening the connection. +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn is_switch_sides_back(conn_type: ConnType, interface: &impl Interface) -> bool { + if conn_type != ConnType::DEFAULT_CONN { + return false; + } + let (id, uuid) = { + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let Some(uuid) = lc.switch_uuid.as_deref() else { + return false; + }; + let Ok(uuid) = Uuid::parse_str(uuid) else { + return false; + }; + (lc.id.clone(), uuid) + }; + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Check, + ) + .await + { + return false; + } + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let current_uuid = lc + .switch_uuid + .as_deref() + .and_then(|value| Uuid::parse_str(value).ok()); + lc.id == id && current_uuid.as_ref() == Some(&uuid) +} + +#[cfg(not(all(feature = "flutter", not(any(target_os = "android", target_os = "ios")))))] +async fn is_switch_sides_back(_conn_type: ConnType, _interface: &impl Interface) -> bool { + false +} + #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { +async fn request_local_switch_sides_uuid( + id: &str, + uuid: &Uuid, + action: crate::ipc::SwitchSidesUuidAction, +) -> bool { let Ok(mut conn) = crate::ipc::connect(1000, "").await else { return false; }; @@ -3444,6 +3512,7 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { .send(&crate::ipc::Data::SwitchSidesUuid( uuid.clone(), id.to_owned(), + action, None, )) .await @@ -3455,9 +3524,10 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { Ok(Some(crate::ipc::Data::SwitchSidesUuid( returned_uuid, returned_id, + returned_action, Some(true), ))) => { - returned_uuid == uuid && returned_id == id + returned_uuid == uuid && returned_id == id && returned_action == action } _ => false, } @@ -3478,7 +3548,7 @@ pub async fn handle_hash( hash: Hash, interface: &impl Interface, peer: &mut Stream, -) { +) -> bool { lc.write().unwrap().hash = hash.clone(); // Take care of password application order @@ -3490,16 +3560,35 @@ pub async fn handle_hash( if let Some(uuid) = uuid { if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { let id = lc.read().unwrap().id.clone(); - if !consume_local_switch_sides_uuid(&id, &uuid).await { + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Consume, + ) + .await + { log::warn!("Ignored untrusted switch_uuid"); } else { lc.write().unwrap().allow_switch_back_once(); send_switch_login_request(lc.clone(), peer, uuid).await; lc.write().unwrap().password_source = Default::default(); - return; + return true; } } } + // Incoming-only may connect out solely for a verified switch-back; + // never fall through to password login, including on repeated hashes. + if config::is_incoming_only() { + interface.msgbox("error", "Connection Error", "Incoming only mode", ""); + let mut misc = Misc::new(); + misc.set_close_reason( + "Connection not allowed in incoming-only mode".to_owned(), + ); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + return false; + } } // last password let mut password = lc.read().unwrap().password.clone(); @@ -3562,7 +3651,7 @@ pub async fn handle_hash( interface.msgbox("terminal-admin-login", "", "", ""); } lc.write().unwrap().hash = hash; - return; + return true; } let password = if password.is_empty() { @@ -3588,6 +3677,7 @@ pub async fn handle_hash( send_login(lc.clone(), os_username, os_password, password, peer).await; lc.write().unwrap().hash = hash; + true } #[inline] @@ -3715,7 +3805,7 @@ pub trait Interface: Send + Clone + 'static + Sized { fn on_error(&self, err: &str) { self.msgbox("error", "Error", err, ""); } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream); + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool; async fn handle_login_from_ui( &self, os_username: String, @@ -3736,6 +3826,16 @@ pub trait Interface: Send + Clone + 'static + Sized { self.get_lch().read().unwrap().force_relay } + fn get_switch_code(&self) -> String { + match self.get_lch().read().unwrap().switch_uuid.clone() { + Some(u) if !u.is_empty() => { + use hbb_common::sodiumoxide::crypto::hash::sha256; + crate::encode64(sha256::hash(u.as_bytes()).0) + } + _ => String::new(), + } + } + fn swap_modifier_mouse(&self, _msg: &mut hbb_common::protos::message::MouseEvent) {} fn update_direct(&self, direct: Option) { @@ -3792,6 +3892,7 @@ pub trait Interface: Send + Clone + 'static + Sized { #[derive(Clone)] pub enum Data { Close, + RejectInsecureConnection, Login((String, String, String, bool)), Message(Message), SendFiles((i32, JobType, String, String, i32, bool, bool)), @@ -3815,11 +3916,33 @@ pub enum Data { ElevateWithLogon(String, String), NewVoiceCall, CloseVoiceCall, + ContinueInsecureConnection, ResetDecoder(Option), RenameFile((i32, String, String, bool)), TakeScreenshot((i32, String)), } +pub async fn confirm_insecure_connection( + interface: &impl Interface, + receiver: &mut UnboundedReceiver, +) -> bool { + interface.msgbox( + "insecure-connection-nocancel-hasclose", + "Insecure Connection", + "conn-e2ee-unavailable-tip", + "", + ); + while let Some(data) = receiver.recv().await { + match data { + Data::ContinueInsecureConnection => return true, + Data::RejectInsecureConnection => return false, + Data::Close => return false, + _ => {} + } + } + false +} + /// Keycode for key events. #[derive(Clone, Debug)] pub enum Key { @@ -3976,9 +4099,25 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b && !text.to_lowercase().contains("mismatch") && !text.to_lowercase().contains("manually") && !text.to_lowercase().contains("restricted") + && !text.to_lowercase().contains("incoming only") && !text.to_lowercase().contains("not allowed"))) } +#[cfg(test)] +mod retry_tests { + use super::check_if_retry; + + #[test] + fn incoming_only_error_is_not_retryable() { + assert!(!check_if_retry( + "error", + "Connection Error", + "Incoming only mode", + false, + )); + } +} + pub async fn hc_connection( feedback: i32, rendezvous_server: String, diff --git a/src/client/file_trait.rs b/src/client/file_trait.rs index 003767bcbb9..bd23883e50e 100644 --- a/src/client/file_trait.rs +++ b/src/client/file_trait.rs @@ -6,7 +6,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn get_home_dir(&self) -> String { @@ -16,7 +15,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn get_next_job_id(&self) -> i32 { @@ -26,7 +24,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn update_next_job_id(&self, id: i32) { @@ -36,7 +33,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn read_dir(&self, path: String, include_hidden: bool) -> sciter::Value { @@ -90,7 +86,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn confirm_delete_files(&self, id: i32, file_num: i32) { @@ -100,7 +95,6 @@ pub trait FileManager: Interface { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn set_no_confirm(&self, id: i32) { diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 68cd6970046..33ee933570d 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -14,6 +14,7 @@ use crate::{ // Empirical no-data window before exposing the restart reconnect state to the UI. // 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); #[cfg(feature = "unix-file-copy-paste")] use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip}; #[cfg(any( @@ -183,8 +184,20 @@ impl Remote { .lock() .unwrap() .set_connected(); + let is_secured = peer.is_secured(); self.handler - .set_connection_type(peer.is_secured(), direct, stream_type); // flutter -> connection_ready + .set_connection_type(is_secured, direct, stream_type); // flutter -> connection_ready + if !is_secured + && !crate::common::is_direct_ip_access(&self.handler.get_id()) + && !client::confirm_insecure_connection(&self.handler, &mut self.receiver).await + { + self.send_close_reason(&mut peer, "").await; + if kcp.is_some() { + tokio::time::sleep(KCP_CLOSE_REASON_FLUSH_DELAY).await; + } + self.handle_disconnected(round); + return; + } self.handler.update_direct(Some(direct)); if conn_type == ConnType::DEFAULT_CONN || conn_type == ConnType::VIEW_CAMERA { self.handler @@ -338,13 +351,17 @@ impl Remote { self.send_close_reason(&mut peer, "kcp").await; // KCP does not send messages immediately, so wait to ensure the last message is sent. // 1ms works in my test, but 30ms is more reliable. - tokio::time::sleep(Duration::from_millis(30)).await; + tokio::time::sleep(KCP_CLOSE_REASON_FLUSH_DELAY).await; } } Err(err) => { self.handler.on_establish_connection_error(err.to_string()); } } + self.handle_disconnected(round); + } + + fn handle_disconnected(&self, round: u32) { // set_disconnected_ok is used to check if new connection round is started. let _set_disconnected_ok = self .handler @@ -393,7 +410,7 @@ impl Remote { || !self.is_connected || !(server_file_transfer_enabled && file_transfer_enabled)); log::debug!( - "Process clipboard message from system, stop: {}, is_stopping_allowed: {}, view_only: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}", + "Process clipboard message from system, view_only: {}, stop: {}, is_stopping_allowed: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}", view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled ); if stop { @@ -1090,6 +1107,9 @@ impl Remote { } async fn send_toggle_virtual_display_msg(&self, peer: &mut Stream) { + if self.handler.is_view_camera() { + return; + } if !self.peer_info.is_support_virtual_display() { return; } @@ -1111,6 +1131,9 @@ impl Remote { } async fn send_toggle_privacy_mode_msg(&self, peer: &mut Stream) { + if self.handler.is_view_camera() { + return; + } let lc = self.handler.lc.read().unwrap(); if lc.version >= hbb_common::get_version_number("1.2.4") && lc.get_toggle_option("privacy-mode") @@ -1330,9 +1353,13 @@ impl Remote { } } Some(message::Union::Hash(hash)) => { - self.handler + if !self + .handler .handle_hash(&self.handler.password.clone(), hash, peer) - .await; + .await + { + return false; + } } Some(message::Union::LoginResponse(lr)) => match lr.union { Some(login_response::Union::Error(err)) => { @@ -1410,14 +1437,6 @@ impl Remote { #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] crate::flutter::update_file_clipboard_required(); - - // on connection established client - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::plugin::handle_listen_event( - crate::plugin::EVENT_ON_CONN_CLIENT.to_owned(), - self.handler.get_id(), - ); } if self.handler.is_file_transfer() { @@ -1961,26 +1980,6 @@ impl Remote { ); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginRequest(p)) => { - allow_err!(crate::plugin::handle_server_event( - &p.id, - &self.handler.get_id(), - &p.content - )); - // to-do: show message box on UI when error occurs? - } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginFailure(p)) => { - let name = if p.name.is_empty() { - "plugin".to_string() - } else { - p.name - }; - self.handler.msgbox("custom-nocancel", &name, &p.msg, ""); - } Some(misc::Union::SupportedEncoding(e)) => { log::info!("update supported encoding:{:?}", e); self.handler.lc.write().unwrap().supported_encoding = e; @@ -2261,12 +2260,8 @@ impl Remote { .msgbox("custom-error", "Privacy mode", "Peer denied", ""); self.update_privacy_mode(impl_key, false); } - back_notification::PrivacyModeState::PrvOnFailedPlugin => { - self.handler - .msgbox("custom-error", "Privacy mode", "Please install plugins", ""); - self.update_privacy_mode(impl_key, false); - } - back_notification::PrivacyModeState::PrvOnFailed => { + back_notification::PrivacyModeState::PrvOnFailedPlugin + | back_notification::PrivacyModeState::PrvOnFailed => { self.handler.msgbox( "custom-error", "Privacy mode", diff --git a/src/clipboard.rs b/src/clipboard.rs index 01dc0c9ed1c..c7c01d6c495 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3; #[cfg(not(target_os = "android"))] const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33); +#[cfg(not(target_os = "android"))] +fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> { + let width = usize::try_from(width).ok()?; + let height = usize::try_from(height).ok()?; + if width == 0 || height == 0 { + return None; + } + let expected_len = width.checked_mul(height)?.checked_mul(4)?; + (data_len == expected_len).then_some((width, height)) +} + #[cfg(not(target_os = "android"))] const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ ClipboardFormat::Text, @@ -722,11 +733,15 @@ mod proto { Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text), Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf), Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html), - Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba( - clipboard.width as _, - clipboard.height as _, - data.into(), - ))), + Ok(ClipboardFormat::ImageRgba) => { + let (width, height) = + super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?; + Some(ClipboardData::Image(arboard::ImageData::rgba( + width, + height, + data.into(), + ))) + } Ok(ClipboardFormat::ImagePng) => { Some(ClipboardData::Image(arboard::ImageData::png(data.into()))) } @@ -770,6 +785,22 @@ mod proto { } } +#[cfg(all(test, not(target_os = "android")))] +mod rgba_tests { + use super::valid_rgba_dimensions; + + #[test] + fn validates_dimensions_against_content_length() { + assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1))); + assert_eq!(valid_rgba_dimensions(1, 1, 3), None); + assert_eq!(valid_rgba_dimensions(-1, 1, 4), None); + assert_eq!(valid_rgba_dimensions(0, 1, 0), None); + assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None); + #[cfg(target_pointer_width = "32")] + assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None); + } +} + #[cfg(target_os = "android")] pub fn handle_msg_clipboard(mut cb: Clipboard) { use hbb_common::protobuf::Message; diff --git a/src/common.rs b/src/common.rs index e404f4f9daf..1a99e21c4e1 100644 --- a/src/common.rs +++ b/src/common.rs @@ -122,6 +122,8 @@ impl Drop for SimpleCallOnReturn { } pub fn global_init() -> bool { + #[cfg(all(target_os = "linux", feature = "drm"))] + crate::platform::linux::dispatch_wayland_display_probe(); #[cfg(target_os = "linux")] { if !crate::platform::linux::is_x11() { @@ -764,15 +766,14 @@ async fn test_rendezvous_server_() { Config::reset_online(); } -// #[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))] pub fn test_rendezvous_server() { std::thread::spawn(test_rendezvous_server_); } pub fn refresh_rendezvous_server() { - #[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))] + #[cfg(any(target_os = "android", target_os = "ios"))] test_rendezvous_server(); - #[cfg(not(any(target_os = "android", target_os = "ios", feature = "cli")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] std::thread::spawn(|| { if crate::ipc::test_rendezvous_server().is_err() { test_rendezvous_server(); @@ -1025,7 +1026,7 @@ pub fn get_full_name() -> String { } pub fn is_setup(name: &str) -> bool { - name.to_lowercase().ends_with("install.exe") + !config::is_disable_installation() && name.to_lowercase().ends_with("install.exe") } pub fn get_custom_rendezvous_server(custom: String) -> String { @@ -1406,6 +1407,58 @@ pub async fn post_request(url: String, body: String, header: &str) -> ResultType .await } +/// POST request via TCP proxy, preserving the HTTP status code. +async fn post_request_via_tcp_proxy_status( + url: &str, + body: &str, + header: &str, +) -> ResultType<(u16, String)> { + let headers = parse_simple_header(header); + let resp = tcp_proxy_request("POST", url, body.as_bytes(), headers).await?; + if !resp.error.is_empty() { + bail!("TCP proxy error: {}", resp.error); + } + Ok(( + resp.status as u16, + String::from_utf8_lossy(&resp.body).to_string(), + )) +} + +/// Like `post_request`, but returns the HTTP status code so callers can tell +/// a server-side failure from success. Same fallback rules: on connection +/// failure or 5xx, retry once through the raw TCP proxy when eligible. +pub async fn post_request_with_status( + url: String, + body: String, + header: &str, +) -> ResultType<(u16, String)> { + if should_use_raw_tcp_for_api(&url) { + return post_request_via_tcp_proxy_status(&url, &body, header).await; + } + let http_result = post_request_http(&url, &body, header).await; + let should_fallback = match &http_result { + Err(_) => true, + Ok((status, _)) => *status >= 500, + }; + if should_fallback && can_fallback_to_raw_tcp(&url) { + log::warn!( + "HTTP POST to {} failed or 5xx (result: {:?}), trying TCP proxy fallback", + tcp_proxy_log_target(&url), + http_result + .as_ref() + .map(|(s, _)| *s) + .map_err(|e| e.to_string()), + ); + match post_request_via_tcp_proxy_status(&url, &body, header).await { + Ok(resp) => return Ok(resp), + Err(tcp_err) => { + log::warn!("TCP proxy fallback also failed: {:?}", tcp_err); + } + } + } + http_result +} + #[async_recursion] async fn post_request_( url: &str, @@ -2621,6 +2674,24 @@ pub fn get_control_permission( } } +pub fn is_direct_ip_access(peer: &str) -> bool { + hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer) +} + +// Align the maximum length of the peer id to the maximum length of the peer id in the server. +const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253; +const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*']; + +// Shared validation for peer/connect ids that cross untrusted boundaries before +// they are stored or written into command/script contexts. +pub fn is_valid_untrusted_peer_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= MAX_UNTRUSTED_PEER_ID_LEN + && !id.chars().any(|ch| { + ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2651,6 +2722,29 @@ mod tests { ) } + #[test] + fn untrusted_peer_id_validation() { + let cases = [ + ("123456789", true), + ("m\u{00FC}nchen-pc", true), + ("192.168.1.10:21118", true), + ("9123456234@public", true), + ( + r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#, + false, + ), + ("", false), + ("peer id", false), + ("peer\nid", false), + ("peer/id", false), + ("peer?id", false), + ]; + + for (id, expected) in cases { + assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}"); + } + } + // ThrottledInterval tick at the same time as tokio interval, if no sleeps #[allow(non_snake_case)] #[tokio::test] diff --git a/src/core_main.rs b/src/core_main.rs index 6b437a98845..3a190f1148b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -127,6 +127,13 @@ pub fn core_main() -> Option> { if args.contains(&"--noinstall".to_string()) { args.clear(); } + // The portable wrapper injects `--install` when its name ends with `install.exe`, + // including `no-install.exe`. Drop the argument instead of exiting so disabled + // clients can continue running as portable applications. + if config::is_disable_installation() { + args.retain(|arg| arg != "--install"); + flutter_args.retain(|arg| arg != "--install"); + } if args.len() > 0 { if args[0] == "--version" { println!("{}", crate::VERSION); @@ -183,9 +190,6 @@ pub fn core_main() -> Option> { crate::platform::elevate_or_run_as_system(click_setup, _is_elevate, _is_run_as_system); return None; } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - init_plugins(&args); if args.is_empty() || crate::common::is_empty_uni_link(&args[0]) { #[cfg(target_os = "macos")] { @@ -660,7 +664,8 @@ pub fn core_main() -> Option> { None } }; - let new_id = get_value("--id"); + // An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146). + let new_id = get_value("--id").filter(|s| !s.is_empty()); match crate::ui_interface::deploy_device(token, new_id) { crate::ui_interface::DeployResult::Ok => { println!("Device deployed."); @@ -729,22 +734,6 @@ pub fn core_main() -> Option> { crate::platform::gtk_sudo::exec(); } return None; - } else { - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if args[0] == "--plugin-install" { - if args.len() == 2 { - crate::plugin::change_uninstall_plugin(&args[1], false); - } else if args.len() == 3 { - crate::plugin::install_plugin_with_url(&args[1], &args[2]); - } - return None; - } else if args[0] == "--plugin-uninstall" { - if args.len() == 2 { - crate::plugin::change_uninstall_plugin(&args[1], true); - } - return None; - } } } //_async_logger_holder.map(|x| x.flush()); @@ -754,23 +743,6 @@ pub fn core_main() -> Option> { return Some(args); } -#[inline] -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -fn init_plugins(args: &Vec) { - if args.is_empty() || "--server" == (&args[0] as &str) { - #[cfg(debug_assertions)] - let load_plugins = true; - #[cfg(not(debug_assertions))] - let load_plugins = crate::platform::is_installed(); - if load_plugins { - crate::plugin::init(); - } - } else if "--service" == (&args[0] as &str) { - hbb_common::allow_err!(crate::plugin::remove_uninstalled()); - } -} - fn import_config(path: &str) { use hbb_common::{config::*, get_exe_time, get_modified_time}; let path2 = path.replace(".toml", "2.toml"); diff --git a/src/flutter.rs b/src/flutter.rs index 73f2dbde325..87c9c02af83 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c return std::ptr::null_mut() as _; } +#[cfg(windows)] +#[no_mangle] +pub extern "C" fn rustdesk_is_disable_installation() -> c_int { + hbb_common::config::is_disable_installation() as c_int +} + // https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6 #[cfg(windows)] fn rust_args_to_c_args(args: Vec, outlen: *mut c_int) -> *mut *mut c_char { @@ -219,8 +225,6 @@ pub struct FlutterHandler { session_handlers: Arc>>, display_rgbas: Arc>>, peer_info: Arc>, - #[cfg(not(any(target_os = "android", target_os = "ios")))] - hooks: Arc>>, use_texture_render: Arc, } @@ -230,8 +234,6 @@ impl Default for FlutterHandler { session_handlers: Default::default(), display_rgbas: Default::default(), peer_info: Default::default(), - #[cfg(not(any(target_os = "android", target_os = "ios")))] - hooks: Default::default(), use_texture_render: Arc::new( AtomicBool::new(crate::ui_interface::use_texture_render()), ), @@ -630,30 +632,6 @@ impl FlutterHandler { serde_json::ser::to_string(&msg_vec).unwrap_or("".to_owned()) } - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub(crate) fn add_session_hook(&self, key: String, hook: SessionHook) -> bool { - let mut hooks = self.hooks.write().unwrap(); - if hooks.contains_key(&key) { - // Already has the hook with this key. - return false; - } - let _ = hooks.insert(key, hook); - true - } - - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub(crate) fn remove_session_hook(&self, key: &String) -> bool { - let mut hooks = self.hooks.write().unwrap(); - if !hooks.contains_key(key) { - // The hook with this key does not found. - return false; - } - let _ = hooks.remove(key); - true - } - pub fn update_use_texture_render(&self) { self.use_texture_render .store(crate::ui_interface::use_texture_render(), Ordering::Relaxed); @@ -1188,15 +1166,6 @@ impl InvokeUiSession for FlutterHandler { impl FlutterHandler { #[inline] fn on_rgba_soft_render(&self, display: usize, rgba: &mut scrap::ImageRgb) { - // Give a chance for plugins or etc to hook a rgba data. - #[cfg(not(any(target_os = "android", target_os = "ios")))] - for (key, hook) in self.hooks.read().unwrap().iter() { - match hook { - SessionHook::OnSessionRgba(cb) => { - cb(key.to_owned(), rgba); - } - } - } // If the current rgba is not fetched by flutter, i.e., is valid. // We give up sending a new event to flutter. let mut rgba_write_lock = self.display_rgbas.write().unwrap(); @@ -1437,7 +1406,7 @@ fn try_send_close_event(event_stream: &Option>) { pub fn update_text_clipboard_required() { let is_required = sessions::get_sessions() .iter() - .any(|s| s.is_text_clipboard_required()); + .any(|s| s.is_default() && s.is_text_clipboard_required()); #[cfg(target_os = "android")] let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required); Client::set_is_text_clipboard_required(is_required); @@ -1447,13 +1416,16 @@ pub fn update_text_clipboard_required() { pub fn update_file_clipboard_required() { let is_required = sessions::get_sessions() .iter() - .any(|s| s.is_file_clipboard_required()); + .any(|s| s.is_default() && s.is_file_clipboard_required()); Client::set_is_file_clipboard_required(is_required); } #[cfg(not(target_os = "ios"))] pub fn send_clipboard_msg(msg: Message, _is_file: bool) { for s in sessions::get_sessions() { + if !s.is_default() { + continue; + } #[cfg(feature = "unix-file-copy-paste")] if _is_file { if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version) @@ -1954,12 +1926,6 @@ pub fn session_on_waiting_for_image_dialog_show(session_id: SessionID) { } } -/// Hooks for session. -#[derive(Clone)] -pub enum SessionHook { - OnSessionRgba(fn(String, &mut scrap::ImageRgb)), -} - #[inline] pub fn get_cur_session() -> Option { sessions::get_session_by_session_id(&*CUR_SESSION_ID.read().unwrap()) @@ -2122,6 +2088,45 @@ pub mod sessions { s } + /// Close every client session, returning how many peer sessions were closed. + /// + /// Used when the UI is gone but the process keeps running, e.g. the Android + /// task is swiped away from recents while a foreground service keeps the + /// process alive. The orphaned `io_loop` would otherwise keep answering + /// `TestDelay`, so the peer never hits its inactivity timeout and the + /// session stays established with no way to close it. + #[cfg(any(target_os = "android", target_os = "ios"))] + pub fn close_all_sessions() -> usize { + // Release held keys before draining: the release path sends through + // `get_cur_session()`, which resolves against SESSIONS, so draining + // first would take TO_RELEASE and then silently drop every key-up, + // leaving the key stuck on the controlled side. A no-op when nothing + // is held. + crate::keyboard::release_remote_keys("map"); + // Drain so the map lock is released before closing each session. + let sessions: Vec = SESSIONS + .write() + .unwrap() + .drain() + .map(|(_, session)| session) + .collect(); + for session in sessions.iter() { + let session_ids: Vec = session + .ui_handler + .session_handlers + .read() + .unwrap() + .keys() + .cloned() + .collect(); + for session_id in session_ids { + session.close_event_stream(session_id); + } + session.close(); + } + sessions.len() + } + /// Check if removing a session by session_id would result in removing the entire peer. /// /// Returns: diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 282b3561e20..39f0a14ef40 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -12,9 +12,6 @@ use crate::{ ui_interface::{self, *}, }; use flutter_rust_bridge::{StreamSink, SyncReturn}; -#[cfg(feature = "plugin_framework")] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use hbb_common::allow_err; use hbb_common::{ config::{self, LocalConfig, PeerConfig, PeerInfoSerde}, fs, lazy_static, log, @@ -1026,7 +1023,7 @@ pub fn main_set_option(key: String, value: String) { set_option(key, value.clone()); #[cfg(target_os = "android")] crate::rendezvous_mediator::RendezvousMediator::restart(); - #[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))] + #[cfg(any(target_os = "android", target_os = "ios"))] crate::common::test_rendezvous_server(); } else { set_option(key, value.clone()); @@ -1224,9 +1221,14 @@ pub fn main_set_local_option(key: String, value: String) { let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER); let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER); set_local_option(key, value.clone()); + let is_render_target = + |session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera(); if is_texture_render_key { let session_event = [("v", &value)]; for session in sessions::get_sessions() { + if !is_render_target(&session) { + continue; + } session.push_event("use_texture_render", &session_event, &[]); session.use_texture_render_changed(); session.ui_handler.update_use_texture_render(); @@ -1234,6 +1236,9 @@ pub fn main_set_local_option(key: String, value: String) { } if is_d3d_render_key { for session in sessions::get_sessions() { + if !is_render_target(&session) { + continue; + } session.update_supported_decodings(); } } @@ -2514,180 +2519,6 @@ pub fn send_url_scheme(_url: String) { std::thread::spawn(move || crate::handle_url_scheme(_url)); } -#[inline] -pub fn plugin_event(_id: String, _peer: String, _event: Vec) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::handle_ui_event(&_id, &_peer, &_event)); - } -} - -pub fn plugin_register_event_stream(_id: String, _event2ui: StreamSink) { - #[cfg(feature = "plugin_framework")] - { - crate::plugin::native_handlers::session::session_register_event_stream(_id, _event2ui); - } -} - -#[inline] -pub fn plugin_get_session_option( - _id: String, - _peer: String, - _key: String, -) -> SyncReturn> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn(crate::plugin::PeerConfig::get(&_id, &_peer, &_key)) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(None) - } -} - -#[inline] -pub fn plugin_set_session_option(_id: String, _peer: String, _key: String, _value: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - let _res = crate::plugin::PeerConfig::set(&_id, &_peer, &_key, &_value); - } -} - -#[inline] -pub fn plugin_get_shared_option(_id: String, _key: String) -> SyncReturn> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn(crate::plugin::ipc::get_config(&_id, &_key).unwrap_or(None)) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(None) - } -} - -#[inline] -pub fn plugin_set_shared_option(_id: String, _key: String, _value: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::set_config(&_id, &_key, _value)); - } -} - -#[inline] -pub fn plugin_reload(_id: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::reload_plugin(&_id,)); - allow_err!(crate::plugin::reload_plugin(&_id)); - } -} - -#[inline] -pub fn plugin_enable(_id: String, _v: bool) -> SyncReturn<()> { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - allow_err!(crate::plugin::ipc::set_manager_plugin_config( - &_id, - "enabled", - _v.to_string() - )); - if _v { - allow_err!(crate::plugin::load_plugin(&_id)); - } else { - crate::plugin::unload_plugin(&_id); - } - } - SyncReturn(()) -} - -pub fn plugin_is_enabled(_id: String) -> SyncReturn { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - SyncReturn( - match crate::plugin::ipc::get_manager_plugin_config(&_id, "enabled") { - Ok(Some(enabled)) => bool::from_str(&enabled).unwrap_or(false), - _ => false, - }, - ) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(false) - } -} - -pub fn plugin_feature_is_enabled() -> SyncReturn { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - #[cfg(debug_assertions)] - let enabled = true; - #[cfg(not(debug_assertions))] - let enabled = is_installed(); - SyncReturn(enabled) - } - #[cfg(any( - not(feature = "plugin_framework"), - target_os = "android", - target_os = "ios" - ))] - { - SyncReturn(false) - } -} - -pub fn plugin_sync_ui(_sync_to: String) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - if plugin_feature_is_enabled().0 { - crate::plugin::sync_ui(_sync_to); - } - } -} - -pub fn plugin_list_reload() { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::plugin::load_plugin_list(); - } -} - -pub fn plugin_install(_id: String, _b: bool) { - #[cfg(feature = "plugin_framework")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - if _b { - if let Err(e) = crate::plugin::install_plugin(&_id) { - log::error!("Failed to install plugin '{}': {}", _id, e); - } - } else { - crate::plugin::uninstall_plugin(&_id, true); - } - } -} - pub fn is_support_multi_ui_session(version: String) -> SyncReturn { SyncReturn(crate::common::is_support_multi_ui_session(&version)) } @@ -3020,6 +2851,16 @@ pub fn main_set_common(_key: String, _value: String) { } } +pub fn session_set_common(session_id: SessionID, key: String, value: String) { + if let Some(s) = sessions::get_session_by_session_id(&session_id) { + if key == "continue-insecure-connection" + { + s.continue_insecure_connection(value == "Y"); + return; + } + } +} + pub fn session_get_common_sync( session_id: SessionID, key: String, @@ -3116,6 +2957,16 @@ pub mod server_side { crate::server::video_service::refresh() } + /// Close outgoing sessions when the UI goes away but the process may not, + /// so a session cannot outlive the UI that is able to close it. + #[no_mangle] + pub unsafe extern "system" fn Java_ffi_FFI_closeAllSessions(_env: JNIEnv, _class: JClass) { + let closed = crate::flutter::sessions::close_all_sessions(); + if closed > 0 { + log::info!("closed {} outgoing session(s)", closed); + } + } + #[no_mangle] pub unsafe extern "system" fn Java_ffi_FFI_getLocalOption( env: JNIEnv, diff --git a/src/hbbs_http.rs b/src/hbbs_http.rs index 9e4538697a2..e33c811d4e5 100644 --- a/src/hbbs_http.rs +++ b/src/hbbs_http.rs @@ -9,8 +9,8 @@ mod http_client; pub mod record_upload; pub mod sync; pub use http_client::{ - create_http_client_async, create_http_client_async_with_url, create_http_client_with_url, - get_url_for_tls, + create_http_client_async, create_http_client_async_with_url_strict, + create_http_client_with_url, create_http_client_with_url_strict, get_url_for_tls, }; #[derive(Debug)] diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 3f824113b17..634c9538398 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -113,7 +113,7 @@ pub struct OidcSession { failed_msg: String, code_url: Option, auth_body: Option, - keep_querying: bool, + auth_attempt: u64, running: bool, query_timeout: Duration, } @@ -140,7 +140,7 @@ impl OidcSession { failed_msg: "".to_owned(), code_url: None, auth_body: None, - keep_querying: false, + auth_attempt: 0, running: false, query_timeout: Duration::from_secs(QUERY_TIMEOUT_SECS), } @@ -169,6 +169,7 @@ impl OidcSession { "id": id, "uuid": uuid, "deviceInfo": crate::ui_interface::get_login_device_info(), + "apiDomain": api_server, }) .to_string(); let resp = crate::post_request_sync(format!("{}/api/oidc/auth", api_server), body, "")?; @@ -191,12 +192,8 @@ impl OidcSession { body: String, } - let resp = crate::http_request_sync( - url.to_string(), - "GET".to_owned(), - None, - "{}".to_owned(), - )?; + let resp = + crate::http_request_sync(url.to_string(), "GET".to_owned(), None, "{}".to_owned())?; let resp = serde_json::from_str::(&resp)?; HbbHttpResponse::parse(&resp.body) } @@ -204,7 +201,6 @@ impl OidcSession { fn reset(&mut self) { self.state_msg = REQUESTING_ACCOUNT_AUTH; self.failed_msg = "".to_owned(); - self.keep_querying = true; self.running = false; self.code_url = None; self.auth_body = None; @@ -219,49 +215,92 @@ impl OidcSession { self.running = false; } + fn start_auth_attempt(&mut self) -> u64 { + self.auth_attempt = self.auth_attempt.wrapping_add(1); + self.auth_attempt + } + + fn cancel_auth_attempt(&mut self) { + self.auth_attempt = self.auth_attempt.wrapping_add(1); + } + + fn is_current_auth_attempt(&self, auth_attempt: u64) -> bool { + self.auth_attempt == auth_attempt + } + + fn auth_attempt_is_current(auth_attempt: u64) -> bool { + OIDC_SESSION + .read() + .unwrap() + .is_current_auth_attempt(auth_attempt) + } + + fn set_state_if_current(auth_attempt: u64, state_msg: &'static str, failed_msg: String) { + let mut session = OIDC_SESSION.write().unwrap(); + if session.is_current_auth_attempt(auth_attempt) { + session.set_state(state_msg, failed_msg); + } + } + fn sleep(secs: f32) { std::thread::sleep(std::time::Duration::from_secs_f32(secs)); } - fn auth_task(api_server: String, op: String, id: String, uuid: String, remember_me: bool) { + fn auth_task( + api_server: String, + op: String, + id: String, + uuid: String, + remember_me: bool, + auth_attempt: u64, + ) { let auth_request_res = Self::auth(&api_server, &op, &id, &uuid); log::info!("Request oidc auth result: {:?}", &auth_request_res); + if !Self::auth_attempt_is_current(auth_attempt) { + return; + } let code_url = match auth_request_res { Ok(HbbHttpResponse::<_>::Data(code_url)) => code_url, Ok(HbbHttpResponse::<_>::Error(err)) => { - OIDC_SESSION - .write() - .unwrap() - .set_state(REQUESTING_ACCOUNT_AUTH, err); + Self::set_state_if_current(auth_attempt, REQUESTING_ACCOUNT_AUTH, err); return; } Ok(_) => { - OIDC_SESSION - .write() - .unwrap() - .set_state(REQUESTING_ACCOUNT_AUTH, "Invalid auth response".to_owned()); + Self::set_state_if_current( + auth_attempt, + REQUESTING_ACCOUNT_AUTH, + "Invalid auth response".to_owned(), + ); return; } Err(err) => { - OIDC_SESSION - .write() - .unwrap() - .set_state(REQUESTING_ACCOUNT_AUTH, err.to_string()); + Self::set_state_if_current(auth_attempt, REQUESTING_ACCOUNT_AUTH, err.to_string()); return; } }; - OIDC_SESSION - .write() - .unwrap() - .set_state(WAITING_ACCOUNT_AUTH, "".to_owned()); - OIDC_SESSION.write().unwrap().code_url = Some(code_url.clone()); + { + let mut session = OIDC_SESSION.write().unwrap(); + if !session.is_current_auth_attempt(auth_attempt) { + return; + } + session.set_state(WAITING_ACCOUNT_AUTH, "".to_owned()); + session.code_url = Some(code_url.clone()); + } let begin = Instant::now(); let query_timeout = OIDC_SESSION.read().unwrap().query_timeout; - while OIDC_SESSION.read().unwrap().keep_querying && begin.elapsed() < query_timeout { - match Self::query(&api_server, &code_url.code, &id, &uuid) { + while Self::auth_attempt_is_current(auth_attempt) && begin.elapsed() < query_timeout { + let query_result = Self::query(&api_server, &code_url.code, &id, &uuid); + if !Self::auth_attempt_is_current(auth_attempt) { + return; + } + match query_result { Ok(HbbHttpResponse::<_>::Data(auth_body)) => { + let mut session = OIDC_SESSION.write().unwrap(); + if !session.is_current_auth_attempt(auth_attempt) { + return; + } if auth_body.r#type == "access_token" { if remember_me { LocalConfig::set_option( @@ -280,21 +319,15 @@ impl OidcSession { ); } } - OIDC_SESSION - .write() - .unwrap() - .set_state(LOGIN_ACCOUNT_AUTH, "".to_owned()); - OIDC_SESSION.write().unwrap().auth_body = Some(auth_body); + session.set_state(LOGIN_ACCOUNT_AUTH, "".to_owned()); + session.auth_body = Some(auth_body); return; } Ok(HbbHttpResponse::<_>::Error(err)) => { if err.contains("No authed oidc is found") { // ignore, keep querying } else { - OIDC_SESSION - .write() - .unwrap() - .set_state(WAITING_ACCOUNT_AUTH, err); + Self::set_state_if_current(auth_attempt, WAITING_ACCOUNT_AUTH, err); return; } } @@ -309,14 +342,9 @@ impl OidcSession { Self::sleep(QUERY_INTERVAL_SECS); } - if begin.elapsed() >= query_timeout { - OIDC_SESSION - .write() - .unwrap() - .set_state(WAITING_ACCOUNT_AUTH, "timeout".to_owned()); + if begin.elapsed() >= query_timeout && Self::auth_attempt_is_current(auth_attempt) { + Self::set_state_if_current(auth_attempt, WAITING_ACCOUNT_AUTH, "timeout".to_owned()); } - - // no need to handle "keep_querying == false" } fn set_state(&mut self, state_msg: &'static str, failed_msg: String) { @@ -338,11 +366,17 @@ impl OidcSession { uuid: String, remember_me: bool, ) { - Self::auth_cancel(); + let auth_attempt = OIDC_SESSION.write().unwrap().start_auth_attempt(); Self::wait_stop_querying(); - OIDC_SESSION.write().unwrap().before_task(); + { + let mut session = OIDC_SESSION.write().unwrap(); + if !session.is_current_auth_attempt(auth_attempt) { + return; + } + session.before_task(); + } std::thread::spawn(move || { - Self::auth_task(api_server, op, id, uuid, remember_me); + Self::auth_task(api_server, op, id, uuid, remember_me, auth_attempt); OIDC_SESSION.write().unwrap().after_task(); }); } @@ -357,7 +391,7 @@ impl OidcSession { } pub fn auth_cancel() { - OIDC_SESSION.write().unwrap().keep_querying = false; + OIDC_SESSION.write().unwrap().cancel_auth_attempt(); } pub fn get_result() -> AuthResult { diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index 573e7e77c4e..a4fd1fa3261 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -1,4 +1,4 @@ -use super::create_http_client_async_with_url; +use super::create_http_client_async_with_url_strict; use hbb_common::{ bail, lazy_static::lazy_static, @@ -167,7 +167,7 @@ async fn do_download( auto_del_dur: Option, mut rx_cancel: UnboundedReceiver<()>, ) -> ResultType { - let client = create_http_client_async_with_url(&url).await; + let client = create_http_client_async_with_url_strict(&url).await?; let mut is_all_downloaded = false; tokio::select! { diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index 432e5fa3869..4e34054927b 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -1,5 +1,6 @@ use hbb_common::{ async_recursion::async_recursion, + bail, config::{Config, Socks5Server}, log::{self, info}, proxy::{Proxy, ProxyScheme}, @@ -7,6 +8,7 @@ use hbb_common::{ get_cached_tls_accept_invalid_cert, get_cached_tls_type, is_plain, upsert_tls_cache, TlsType, }, + ResultType, }; use reqwest::{blocking::Client as SyncClient, Client as AsyncClient}; @@ -137,6 +139,32 @@ pub fn create_http_client_with_url(url: &str) -> SyncClient { ) } +pub fn create_http_client_with_url_strict(url: &str) -> ResultType { + let parsed_url = url::Url::parse(url)?; + if parsed_url.scheme() != "https" { + bail!("Strict HTTP client requires HTTPS: {}", url); + } + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let cached_tls_type = get_cached_tls_type(tls_url); + let cached_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let can_reuse_cached_probe = + cached_tls_type.is_some() && cached_danger_accept_invalid_cert == Some(false); + let tls_type = if can_reuse_cached_probe { + cached_tls_type.unwrap_or(TlsType::Rustls) + } else { + TlsType::Rustls + }; + Ok(create_http_client_with_url_( + url, + tls_url, + tls_type, + can_reuse_cached_probe, + Some(false), + Some(false), + )) +} + fn create_http_client_with_url_( url: &str, tls_url: &str, @@ -247,6 +275,33 @@ pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient { .await } +pub async fn create_http_client_async_with_url_strict(url: &str) -> ResultType { + let parsed_url = url::Url::parse(url)?; + if parsed_url.scheme() != "https" { + bail!("Strict HTTP client requires HTTPS: {}", url); + } + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let cached_tls_type = get_cached_tls_type(tls_url); + let cached_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let can_reuse_cached_probe = + cached_tls_type.is_some() && cached_danger_accept_invalid_cert == Some(false); + let tls_type = if can_reuse_cached_probe { + cached_tls_type.unwrap_or(TlsType::Rustls) + } else { + TlsType::Rustls + }; + Ok(create_http_client_async_with_url_( + url, + tls_url, + tls_type, + can_reuse_cached_probe, + Some(false), + Some(false), + ) + .await) +} + #[async_recursion] async fn create_http_client_async_with_url_( url: &str, diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index 1bb61943f86..d78f901945d 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -308,3 +308,135 @@ fn handle_config_options(config_options: HashMap) { pub fn is_pro() -> bool { PRO.lock().unwrap().clone() } + +// Fire-and-forget by design: the switch flow must not block on this POST. +// If the device clock is outside the server's accepted window, the server +// returns its current Unix time and this task re-signs and retries once. +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn register_switch_grant(switch_uuid: String) { + tokio::spawn(async move { + let api_server = crate::ui_interface::get_api_server(); + if api_server.is_empty() || crate::is_public(&api_server) { + return; + } + use hbb_common::sodiumoxide::crypto::{hash::sha256, sign}; + let switch_code = crate::encode64(sha256::hash(switch_uuid.as_bytes()).0); + let switch_code_verifier = switch_code_verifier(&switch_code); + let timestamp = (hbb_common::get_time() / 1000).to_string(); + let id = Config::get_id(); + let kp = Config::get_key_pair(); + let Some(sk) = sign::SecretKey::from_slice(&kp.0) else { + log::error!("Failed to register switch grant: no device key"); + return; + }; + let url = format!("{}/api/switch-grant", api_server); + let mut timestamp = timestamp; + for attempt in 0..2 { + let signature = sign::sign_detached( + &switch_grant_signed_msg(&id, &switch_code_verifier, ×tamp), + &sk, + ); + let body = json!({ + "id": &id, + "switch_code_verifier": &switch_code_verifier, + "timestamp": ×tamp, + "signature": crate::encode64(signature.to_bytes()), + }) + .to_string(); + let response = match crate::post_request(url.clone(), body, "").await { + Ok(response) => response, + Err(e) => { + log::error!("Failed to register switch grant: {}", e); + return; + } + }; + let response = match serde_json::from_str::(&response) { + Ok(response) => response, + Err(e) => { + log::error!("Failed to register switch grant: invalid response: {}", e); + return; + } + }; + match response.get("accepted").and_then(Value::as_bool) { + Some(true) => return, + Some(false) => {} + None => { + log::error!("Failed to register switch grant: missing accepted response"); + return; + } + } + let Some(server_time) = response["server_time"].as_i64() else { + log::error!("Failed to register switch grant: rejected by server"); + return; + }; + if attempt == 0 { + log::warn!("Switch grant timestamp rejected, retrying with server time"); + timestamp = server_time.to_string(); + } else { + log::error!("Failed to register switch grant after retrying with server time"); + } + } + }); +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn switch_code_verifier(switch_code: &str) -> String { + use hbb_common::sodiumoxide::crypto::hash::sha256; + + let prefix = b"switch-grant-verifier\0"; + let mut msg = Vec::with_capacity(prefix.len() + switch_code.len()); + msg.extend_from_slice(prefix); + msg.extend_from_slice(switch_code.as_bytes()); + crate::encode64(sha256::hash(&msg).0) +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn switch_grant_signed_msg(id: &str, switch_code_verifier: &str, timestamp: &str) -> Vec { + let mut msg = + Vec::with_capacity(13 + id.len() + 1 + switch_code_verifier.len() + 1 + timestamp.len()); + msg.extend_from_slice(b"switch-grant\0"); + msg.extend_from_slice(id.as_bytes()); + msg.push(0); + msg.extend_from_slice(switch_code_verifier.as_bytes()); + msg.push(0); + msg.extend_from_slice(timestamp.as_bytes()); + msg +} + +#[cfg(all( + test, + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) +))] +mod tests { + use super::{switch_code_verifier, switch_grant_signed_msg}; + + #[test] + fn test_switch_code_verifier_is_not_raw_switch_code() { + let switch_code = "code-abc"; + let verifier = switch_code_verifier(switch_code); + assert_ne!(verifier, switch_code); + assert_eq!(verifier, switch_code_verifier(switch_code)); + assert_eq!( + verifier, + "dMIn3uiPe77XodFB5IKi7PrKJ7l7+zVquNn0ObSaHQc=" + ); + } + + #[test] + fn test_switch_grant_signed_msg_layout() { + let expected: Vec = [ + &b"switch-grant\0"[..], + b"id1", + b"\0", + b"c1", + b"\0", + b"1700000000", + ] + .concat(); + assert_eq!(switch_grant_signed_msg("id1", "c1", "1700000000"), expected); + } +} diff --git a/src/ipc.rs b/src/ipc.rs index 68c987f4ece..9e3faab63fa 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,10 +3,22 @@ mod ipc_auth; #[cfg(any(target_os = "linux", target_os = "macos"))] #[path = "ipc/fs.rs"] mod ipc_fs; +// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own +// module, declared the same way as the other pieces of this file, so the opt-in feature adds a +// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[path = "ipc/drm.rs"] +mod ipc_drm; +// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`, +// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two +// payload types. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo}; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::DrmConn; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::connect_drm; -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::plugin::ipc::Plugin; use crate::{ common::{is_server, CheckTestNatType}, privacy_mode, @@ -41,6 +53,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; +#[cfg(target_os = "macos")] +use ipc_auth::authorize_user_server_process; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, @@ -58,6 +72,9 @@ use ipc_fs::{ check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, should_scrub_parent_entries_after_check_pid, write_pid, }; +// Gated with the module that uses it, so a `drm`-less build does not carry an unused import. +#[cfg(all(target_os = "linux", feature = "drm"))] +use ipc_fs::remove_ipc_entry_via_secure_parent_fd; use parity_tokio_ipc::{ Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, }; @@ -292,6 +309,14 @@ pub enum DataPortableService { CmShowElevation(bool), } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum SwitchSidesUuidAction { + Check, + Consume, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum Data { @@ -367,7 +392,7 @@ pub enum Data { SwitchSidesRequest(String), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - SwitchSidesUuid(String, String, Option), + SwitchSidesUuid(String, String, SwitchSidesUuidAction, Option), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, @@ -376,9 +401,6 @@ pub enum Data { StartVoiceCall, VoiceCallResponse(bool), CloseVoiceCall(String), - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Plugin(Plugin), #[cfg(windows)] SyncWinCpuUsage(Option), FileTransferLog((String, String)), @@ -472,11 +494,58 @@ pub enum Data { #[cfg(target_os = "windows")] PortForwardSessionCount(Option), SocksWs(Option, String)>>), + #[cfg(target_os = "macos")] + HasNoActiveConns(Option), #[cfg(not(any(target_os = "android", target_os = "ios")))] Whiteboard((String, crate::whiteboard::CustomEvent)), ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- + // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical + // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the + // client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and + // `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly + // one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps + // the header extensible. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a + // small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on + // the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body. + /// Client -> service: begin streaming the chosen display. + #[cfg(all(target_os = "linux", feature = "drm"))] + // `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context + // (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the + // CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot + // detile, so a render-node-less seat still captures instead of losing the stream. + DrmStart { display: i32, need_cpu: bool }, + /// Service -> client: the enumerated DRM displays (sent once, before frames). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplayList(Vec), + /// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset, + /// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the + /// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing + /// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same + /// stream; carries no `send_raw()` body and no fd. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplaysChanged(Vec), + /// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`. + /// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrame { width: u32, height: u32 }, + /// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when + /// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and + /// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does + /// the EGL detile/convert itself (see `DmabufDesc`). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrameDmabuf(DmabufDesc), + /// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmCursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + }, } #[tokio::main(flavor = "current_thread")] @@ -881,8 +950,14 @@ async fn handle(data: Data, stream: &mut Connection) { Some(value) => { let mut updated = true; if name == "id" { - Config::set_key_confirmed(false); - Config::set_id(&value); + // An empty id would wipe the local id and unconfirm the key (cf. #15626). + if value.is_empty() { + log::warn!("Ignoring empty id write over IPC"); + updated = false; + } else { + Config::set_key_confirmed(false); + Config::set_id(&value); + } } else if name == "temporary-password" { password::update_temporary_password(); } else if name == "permanent-password" { @@ -968,6 +1043,7 @@ async fn handle(data: Data, stream: &mut Connection) { Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); + crate::hbbs_http::sync::register_switch_grant(uuid.to_string()); allow_err!( stream .send(&Data::SwitchSidesRequest(uuid.to_string())) @@ -976,20 +1052,24 @@ async fn handle(data: Data, stream: &mut Connection) { } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::SwitchSidesUuid(uuid, id, None) => { + Data::SwitchSidesUuid(uuid, id, action, None) => { let allowed = uuid .parse::() - .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .map(|uuid| match action { + SwitchSidesUuidAction::Check => { + crate::server::has_pending_switch_sides_uuid(&id, &uuid) + } + SwitchSidesUuidAction::Consume => { + crate::server::claim_pending_switch_sides_uuid(&id, &uuid) + } + }) .unwrap_or(false); allow_err!( stream - .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .send(&Data::SwitchSidesUuid(uuid, id, action, Some(allowed))) .await ); } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, #[cfg(windows)] Data::ControlledSessionCount(_) => { allow_err!( @@ -1000,6 +1080,16 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(target_os = "macos")] + Data::HasNoActiveConns(None) => { + allow_err!( + stream + .send(&Data::HasNoActiveConns(Some( + crate::updater::has_no_active_conns() + ))) + .await + ); + } #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) @@ -1334,14 +1424,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType ResultType> { let path = Config::ipc_path_for_uid(uid, postfix); - connect_with_path(ms_timeout, &path).await + let conn = connect_with_path(ms_timeout, &path).await?; + #[cfg(target_os = "macos")] + if postfix.is_empty() + && !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid) + { + bail!("Rejected user IPC peer for uid {}", uid); + } + Ok(conn) } #[cfg(target_os = "linux")] @@ -1689,19 +1786,24 @@ pub fn clear_trusted_devices() { } pub fn get_id() -> String { + // An empty id may come from a process that took over the main IPC with a + // config scope that has no id yet (e.g. a user GUI that became the server + // while the installed service was restarting). Treat it as no answer, + // otherwise the empty id is adopted below and wipes the local one. if let Ok(Some(v)) = get_config("id") { - // update salt also, so that next time reinstallation not causing first-time auto-login failure - if let Ok(Some(v2)) = get_config("salt") { - Config::set_salt(&v2); - } - if v != Config::get_id() { - Config::set_key_confirmed(false); - Config::set_id(&v); + if !v.is_empty() { + // update salt also, so that next time reinstallation not causing first-time auto-login failure + if let Ok(Some(v2)) = get_config("salt") { + Config::set_salt(&v2); + } + if v != Config::get_id() { + Config::set_key_confirmed(false); + Config::set_id(&v); + } + return v; } - v - } else { - Config::get_id() } + Config::get_id() } pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec) { diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 77fd148c6cb..89beef072ca 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -208,6 +208,17 @@ pub(crate) fn active_uid() -> Option { active_uid_strict() } +/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0 +/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime +/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream. +// Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a +// drm-off Linux build would carry it as dead code and warn about it. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[inline] +pub(crate) fn active_uid_cached() -> Option { + crate::platform::linux::get_active_userid_cached() +} + #[cfg(any(target_os = "linux", target_os = "macos"))] #[inline] pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { @@ -656,6 +667,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf true } +#[cfg(target_os = "macos")] +pub(crate) fn authorize_user_server_process( + peer_uid: Option, + peer_pid: Option, + expected_uid: u32, +) -> bool { + if peer_uid != Some(expected_uid) { + return false; + } + let Some(peer_pid) = peer_pid else { + return false; + }; + let Ok(peer_exe) = peer_exe_canonical_path_by_pid(peer_pid) else { + return false; + }; + let expected_path = PathBuf::from(format!( + "/Applications/{}.app/Contents/MacOS/{}", + crate::get_app_name(), + crate::get_app_name() + )); + let Ok(expected_path) = fs::canonicalize(expected_path) else { + return false; + }; + paths_refer_to_same_file(&peer_exe, &expected_path) +} + #[cfg(windows)] pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { let ( diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs new file mode 100644 index 00000000000..15e500e6070 --- /dev/null +++ b/src/ipc/drm.rs @@ -0,0 +1,1800 @@ +// The DRM/KMS capture half of the `_drm` IPC channel: types, root-service producer, framing. + +use super::ipc_auth::active_uid_cached; +use super::*; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DrmDisplayInfo { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, + /// Render node of the GPU that EXPORTS this display's scanout; on a multi-GPU host auto-select + /// can bind a different GPU whose cross-vendor import then fails. Empty when the service cannot + /// name it: the consumer then auto-selects on a single-render-node host, and forces the CPU + /// path where there are several. + #[serde(default)] + pub render_node: String, + /// KMS card node (`/dev/dri/card*`) driving this display. crtc_ids are card-local, so the index + /// alone is ambiguous across cards. Empty = the single auto-detected device. + #[serde(default)] + pub device: String, +} + +/// Mirrors `scrap::drm_reader::drmtap_dmabuf_desc` except `dma_buf_fd` (never serializes — it rides +/// SCM_RIGHTS ancillary), and adds `buffer_id` (fb_id tagged with a per-connection epoch; no consumer reads it today) and `has_fd`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DmabufDesc { + pub buffer_id: u64, + pub width: u32, + pub height: u32, + pub format: u32, + pub modifier: u64, + /// KMS framebuffer id — libdrmtap's import-once cache key. 0 disables caching for this frame. + pub fb_id: u32, + /// Used entries in `offsets`/`pitches` (1..4); 0 is treated as 1. + pub num_planes: u32, + pub offsets: [u32; 4], + pub pitches: [u32; 4], + /// DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3). PQ triggers the HDR->SDR tone-map on convert. + pub hdr_eotf: u32, + pub hdr_max_nits: u32, + /// True: the fd rides this message's SCM_RIGHTS cmsg. False: import-once cache hit for `fb_id`. + pub has_fd: bool, +} + +pub(crate) fn drm_ipc_path() -> String { + let service_path = Config::ipc_path("_service"); + let dir = std::path::Path::new(&service_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/tmp")); + dir.join("ipc_drm").to_string_lossy().into_owned() +} + +pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType { + use std::os::fd::AsRawFd; + let path = drm_ipc_path(); + let stream = timeout(ms_timeout, tokio::net::UnixStream::connect(&path)).await??; + // The producer MUST be root: a non-root peer that won a socket-path race must not be trusted to + // supply the display list, frames and an arbitrary dma-buf fd. + if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { + bail!("drm: _drm producer is not root; refusing to consume"); + } + Ok(DrmConn::new(stream)) +} + +/// Bind the `_drm` listener 0666: connectable by any local uid, authorized in `handle_drm_conn`. +fn new_drm_listener() -> ResultType { + let path = drm_ipc_path(); + let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; + // NOT `std::fs::remove_file`: `unlink(2)` returns EISDIR against a directory-typed squatter and + // the bind then fails EADDRINUSE; the fd-based helper picks `AT_REMOVEDIR` (empty dirs only). + if let Err(err) = remove_ipc_entry_via_secure_parent_fd(&path) { + log::warn!("drm: could not clear a stale entry at {}: {}", &path, err); + } + let mut endpoint = Endpoint::new(path.clone()); + endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); + let incoming = endpoint.incoming()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { + std::fs::remove_file(&path).ok(); + err + })?; + log::info!("Started drm ipc server at path: {}", &path); + Ok(incoming) +} + +enum DrmProducerMsg { + /// Enumerated displays, sent once before any frame. + Displays(Vec), + /// Zero-copy path: descriptor + scanout fd; the `OwnedFd` is closed once the send has dup'd it. + Frame { + desc: DmabufDesc, + fd: Option, + }, + /// CPU-mapped fallback (packed BGRA): consumer has no convert context (`need_cpu`), or ENOTSUP. + FrameCpu { + width: u32, + height: u32, + data: Bytes, + }, + Cursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + colors: Vec, + }, +} + +struct DrmStopGuard(std::sync::Arc); +impl Drop for DrmStopGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +fn dup_to_drm_conn(stream: &Connection) -> ResultType { + let raw = stream.inner.get_ref().as_raw_fd(); + // F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec, and this process forks (the + // `loginctl` lookup), so an already-authorized `_drm` socket would leak into children. + let dup = unsafe { hbb_common::libc::fcntl(raw, hbb_common::libc::F_DUPFD_CLOEXEC, 0) }; + if dup < 0 { + return Err(std::io::Error::last_os_error().into()); + } + // SAFETY: `dup` is a freshly dup'd, owned fd for a connected SOCK_STREAM unix socket. + let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(dup) }; + std_stream.set_nonblocking(true)?; + let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; + Ok(DrmConn::new(tokio_stream)) +} + +static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Bumped only when a change altered `DRM_DISPLAY_CACHE`; Release orders it after the cache write. +static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Displays this reader serves, plus the identity (`device:connector`) of each undriven output. +fn drm_displays_from_reader( + reader: &mut scrap::drm_reader::DrmReader, + device: &str, +) -> (Vec, Vec) { + let render_node = reader.render_node().unwrap_or_default(); + let mut undriven = Vec::new(); + let displays: Vec = reader + .displays() + .into_iter() + // Only outputs bound to a CRTC: a CONNECTED-but-unbound connector enumerates with + // `crtc_id == 0`, and `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams ITS frames. + .filter(|d| { + if !d.active || d.crtc_id == 0 { + undriven.push(format!("{device}:{name}", name = d.name)); + return false; + } + true + }) + .map(|d| DrmDisplayInfo { + name: d.name, + crtc_id: d.crtc_id, + x: d.x, + y: d.y, + width: d.width, + height: d.height, + active: d.active, + render_node: render_node.clone(), + device: device.to_owned(), + }) + .collect(); + (displays, undriven) +} + +/// Active displays of every DRM device + the connected-but-undriven identities, from ONE look. +fn drm_enumerate_all_displays() -> (Vec, Vec) { + if let Some(devices) = scrap::drm_reader::list_devices() { + if devices.len() > 1 { + log::info!( + "drm: {} DRM devices: {}", + devices.len(), + devices + .iter() + .map(|d| format!( + "{} ({}, render {})", + d.path, + d.display_count, + if d.render_node.is_empty() { "none" } else { &d.render_node } + )) + .collect::>() + .join(", ") + ); + } + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut any_opened = false; + for dev in devices { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(&dev.path), 0) { + any_opened = true; + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, &dev.path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } else if dev.display_count == 0 { + log::debug!( + "drm: {} has no active display and did not open; cannot tell whether it has a \ + connected output that is merely switched off", + dev.path + ); + } + } + // Take this even when the list is EMPTY: the fallback re-keys identities under `device = ""`. + if any_opened { + return (all, undriven_total); + } + } + // Auto-detect alone is not enough: it picks a card that is SCANNING OUT. Measured on the T2 with + // the panel idle-disabled it binds card0 (the Touch Bar); the panel on card2 is invisible to it. + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut paths: Vec = match std::fs::read_dir("/dev/dri") { + Ok(rd) => rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("card") && n[4..].chars().all(|c| c.is_ascii_digit())) + }) + .collect(), + Err(err) => { + log::debug!("drm: cannot read /dev/dri to enumerate cards: {err}"); + Vec::new() + } + }; + // Deterministic order, so the display list does not depend on directory order. + paths.sort(); + let n_paths = paths.len(); + for p in paths { + let Some(path) = p.to_str() else { continue }; + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(path), 0) { + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } + } + log::info!( + "drm: enumerated /dev/dri directly ({} card path(s)): {} active display(s), {} connected \ + but undriven", + n_paths, + all.len(), + undriven_total.len() + ); + if all.is_empty() && undriven_total.is_empty() { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(None, 0) { + log::info!("drm: no card enumerated by path; falling back to the auto-detected reader"); + return drm_displays_from_reader(&mut r, ""); + } + } + (all, undriven_total) +} + +/// Connectors a wake did NOT bring back. SELF-REFUTING: an entry later seen DRIVEN is removed. +#[cfg(feature = "drm-wake")] +static DRM_WAKE_HOPELESS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(feature = "drm-wake")] +fn drm_wakeable_undriven(displays: &[DrmDisplayInfo], undriven: &[String]) -> Vec { + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !hopeless.is_empty() { + hopeless.retain(|id| { + let driven_now = displays + .iter() + .any(|d| format!("{}:{}", d.device, d.name) == *id); + if driven_now { + log::info!("drm: {id} is scanning out after all; treating it as wakeable again"); + } + !driven_now + }); + } + undriven + .iter() + .filter(|id| !hopeless.iter().any(|h| h == *id)) + .cloned() + .collect() +} + +#[cfg(feature = "drm-wake")] +static DRM_LAST_WAKE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(feature = "drm-wake")] +static DRM_WAKE_UNAVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Wake config key; `enable-` is load-bearing: an absent value reads as `!= "N"`, so it defaults ON. +#[cfg(feature = "drm-wake")] +const OPTION_ENABLE_DRM_DISPLAY_WAKE: &str = "enable-drm-display-wake"; + +#[cfg(feature = "drm-wake")] +const DRM_WAKE_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(20); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_DEVICE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_RECHECK_TOTAL: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_SETTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(5); + +/// Seconds since service start, monotonic: SystemTime would let a clock step re-open the wake gate. +#[cfg(feature = "drm-wake")] +fn drm_wake_clock_secs() -> u64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs() +} + +/// Look like user activity so the compositor re-enables an idle-DISABLED connector (until it does, +/// nothing scans out). Measured on a T2 greeter: one relative move restored a 2880x1800 scanout. +#[cfg(feature = "drm-wake")] +fn drm_wake_displays(reason: &str) -> bool { + use std::sync::atomic::Ordering; + + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return false; + } + let now = drm_wake_clock_secs(); + loop { + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last != 0 && now.saturating_sub(last) < DRM_WAKE_MIN_GAP.as_secs() { + log::debug!( + "drm: not waking displays ({reason}): a wake {}s ago is still recent", + now.saturating_sub(last) + ); + return false; + } + if DRM_LAST_WAKE + .compare_exchange(last, now.max(1), Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + break; + } + } + + // It has to look like a MOUSE: libinput ignores a device with a single relative axis and no + // buttons. Measured: REL_X + REL_Y + BTN_LEFT woke the panel; REL_X alone did not. + let mut axes = evdev::AttributeSet::::new(); + axes.insert(evdev::RelativeAxisType::REL_X); + axes.insert(evdev::RelativeAxisType::REL_Y); + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::Key::BTN_LEFT); + let built = evdev::uinput::VirtualDeviceBuilder::new() + .and_then(|b| b.name("RustDesk DRM display wake").with_relative_axes(&axes)) + .and_then(|b| b.with_keys(&keys)) + .and_then(|b| b.build()); + let mut dev = match built { + Ok(d) => d, + Err(err) => { + DRM_WAKE_UNAVAILABLE.store(true, Ordering::Relaxed); + log::warn!( + "drm: cannot wake displays ({reason}): no uinput device ({err}). A compositor that \ + disabled its outputs will keep them disabled, so there is no scanout to capture \ + until something else generates input. Note input injection needs uinput too, so \ + this session cannot control the host either." + ); + return false; + } + }; + + // A FRESH uinput device is not bound yet; events written before udev binds it are lost. Measured + // back to back: with this pause the panel went `disabled -> enabled`, without it it did not. + std::thread::sleep(DRM_WAKE_DEVICE_SETTLE); + + // +1 then -1: activity with zero net displacement. emit() appends the SYN_REPORT itself. + let step = |v: i32| { + evdev::InputEvent::new( + evdev::EventType::RELATIVE, + evdev::RelativeAxisType::REL_X.0, + v, + ) + }; + let ok = dev.emit(&[step(1)]).and_then(|_| { + std::thread::sleep(std::time::Duration::from_millis(120)); + dev.emit(&[step(-1)]) + }); + if let Err(err) = ok { + log::warn!("drm: display wake ({reason}) failed to emit: {err}"); + return false; + } + log::info!("drm: no display was scanning out ({reason}); asked the compositor to wake up"); + true +} + +#[cfg(not(feature = "drm-wake"))] +fn drm_enumerate_settled(reason: &str) -> Vec { + let (displays, undriven) = drm_enumerate_all_displays(); + if !undriven.is_empty() { + log::debug!( + "drm: {} connected display(s) have no CRTC ({reason}); this build has no display wake", + undriven.len() + ); + } + displays +} + +/// Wake build: wake an undriven display and WAIT for the settled topology. The wait applies to every +/// handshake whose wake may still be in flight, not only the one whose attempt won the rate limit. +#[cfg(feature = "drm-wake")] +fn drm_enumerate_settled(reason: &str) -> Vec { + use std::sync::atomic::Ordering; + + let (displays, undriven) = drm_enumerate_all_displays(); + if !hbb_common::config::Config::get_bool_option(OPTION_ENABLE_DRM_DISPLAY_WAKE) { + if !undriven.is_empty() { + log::info!( + "drm: {} connected display(s) have no CRTC ({reason}), but the display wake is \ + disabled by configuration ({OPTION_ENABLE_DRM_DISPLAY_WAKE}=N)", + undriven.len() + ); + } + return displays; + } + let wakeable = drm_wakeable_undriven(&displays, &undriven); + if wakeable.is_empty() { + return displays; + } + let fired = drm_wake_displays(&format!( + "{reason} and {n} connected display(s) had no CRTC", + n = wakeable.len() + )); + if !fired { + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return displays; + } + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last == 0 + || drm_wake_clock_secs().saturating_sub(last) > DRM_WAKE_SETTLE_WINDOW.as_secs() + { + return displays; + } + } + let before_len = displays.len(); + let deadline = std::time::Instant::now() + DRM_WAKE_RECHECK_TOTAL; + let mut cur = displays; + let mut cur_wakeable = wakeable; + while !cur_wakeable.is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(300)); + let (next, next_undriven) = drm_enumerate_all_displays(); + cur_wakeable = drm_wakeable_undriven(&next, &next_undriven); + cur = next; + } + if cur.len() > before_len { + log::info!( + "drm: {} display(s) came back after the wake ({} -> {}{})", + cur.len() - before_len, + before_len, + cur.len(), + if cur_wakeable.is_empty() { + String::new() + } else { + format!(", {} still undriven", cur_wakeable.len()) + } + ); + schedule_drm_cache_refresh(); + } + if fired && !cur_wakeable.is_empty() { + // Only the handshake that FIRED latches; a loser's baseline was taken mid-transition. + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for id in &cur_wakeable { + if !hopeless.iter().any(|h| h == id) { + hopeless.push(id.clone()); + } + } + log::info!( + "drm: the wake did not bring back {list}; not asking again for {these} until {it_is} \ + seen scanning out", + list = cur_wakeable.join(", "), + these = if cur_wakeable.len() == 1 { "it" } else { "them" }, + it_is = if cur_wakeable.len() == 1 { "it is" } else { "they are" }, + ); + } + cur +} + +/// The SINGLE writer of DRM_DISPLAY_CACHE (+ DRM_DISPLAY_GENERATION), off the caller's thread and +/// SINGLE-FLIGHT: a request arriving during a run coalesces into exactly one follow-up. +fn schedule_drm_cache_refresh() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RUNNING: AtomicBool = AtomicBool::new(false); + static PENDING: AtomicBool = AtomicBool::new(false); + // Ownership of RUNNING, released on every exit incl. unwind and failed spawn; re-taken mid-loop. + struct RefreshSlot(bool); + impl RefreshSlot { + fn release(&mut self) { + if self.0 { + self.0 = false; + RUNNING.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !RUNNING.swap(true, Ordering::AcqRel); + self.0 + } + } + impl Drop for RefreshSlot { + fn drop(&mut self) { + self.release(); + } + } + // Announce a refresh is wanted before trying to run, so an active worker is guaranteed to see it. + PENDING.store(true, Ordering::Release); + if RUNNING.swap(true, Ordering::AcqRel) { + return; // a worker is already active; it will observe PENDING and refresh again + } + let mut slot = RefreshSlot(true); + let spawned = std::thread::Builder::new() + .name("drm-cache-refresh".into()) + .spawn(move || loop { + PENDING.store(false, Ordering::Release); + let fresh = std::panic::catch_unwind(drm_enumerate_all_displays) + .unwrap_or_else(|_| { + log::error!("drm: display enumeration panicked; treating as no displays"); + (Vec::new(), Vec::new()) + }) + .0; + let changed = { + let mut cache = match DRM_DISPLAY_CACHE.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if *cache != fresh { + *cache = fresh; + true + } else { + false + } + }; + if changed { + DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release); + log::info!("drm: display cache refreshed (topology changed)"); + } + // Exit only if no request arrived during this enumeration. The re-check after releasing + // the slot closes the lost-wakeup window (a request that set PENDING just before it). + if !PENDING.load(Ordering::Acquire) { + slot.release(); + if !PENDING.load(Ordering::Acquire) { + break; + } + if !slot.retake() { + break; // another caller re-acquired the slot; it will handle the pending refresh + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the display-cache refresh worker: {err}"); + } +} + +fn uevent_is_drm_change(msg: &[u8]) -> bool { + let mut is_drm = false; + let mut is_change = false; + for rec in msg.split(|&b| b == 0) { + if rec == b"SUBSYSTEM=drm" { + is_drm = true; + } else if rec == b"ACTION=change" || rec == b"HOTPLUG=1" { + is_change = true; + } + } + is_drm && is_change +} + +/// Refresh the display cache on DRM hotplug uevents (raw NETLINK_KOBJECT_UEVENT, no libudev). +fn drm_udev_listener() { + use hbb_common::libc; + + let sock = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if sock < 0 { + log::info!( + "drm: udev uevent socket unavailable ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + let _owned = unsafe { OwnedFd::from_raw_fd(sock) }; + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as u16; + // Group 1 = kernel-originated uevents (udev re-broadcasts on group 2); pid 0 => kernel assigns. + addr.nl_groups = 1; + let rc = unsafe { + libc::bind( + sock, + &addr as *const libc::sockaddr_nl as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc < 0 { + log::info!( + "drm: udev uevent bind failed ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + log::info!("drm: udev DRM-uevent listener started"); + let mut buf = [0u8; 8192]; + loop { + // recvmsg, not recv: a local process could UNICAST a spoofed uevent to this root listener. + let mut src: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() }; + mhdr.msg_name = &mut src as *mut libc::sockaddr_nl as *mut libc::c_void; + mhdr.msg_namelen = std::mem::size_of::() as libc::socklen_t; + mhdr.msg_iov = &mut iov; + mhdr.msg_iovlen = 1; + let n = unsafe { libc::recvmsg(sock, &mut mhdr, 0) }; + if n <= 0 { + let err = std::io::Error::last_os_error(); + if n < 0 && err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + log::info!("drm: udev uevent recv ended ({err}); hotplug refresh stopped"); + break; + } + if (mhdr.msg_namelen as usize) < std::mem::size_of::() + || src.nl_pid != 0 + || src.nl_groups == 0 + { + continue; + } + if !uevent_is_drm_change(&buf[..n as usize]) { + continue; + } + schedule_drm_cache_refresh(); + } +} + +fn drm_prewarm() { + // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: + // "x11" 0.8 s into a boot on a Wayland host). `is_x11_for_drm()` is that path minus the + // greeter blind spot, which a login screen never leaves. + const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); + const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); + let waited = std::time::Instant::now(); + while crate::platform::linux::is_x11_for_drm() { + if waited.elapsed() >= PREWARM_SESSION_BUDGET { + log::info!( + "drm: session still reads as X11 after {:?}; skipping the pre-warm \ + (the _drm listener still runs)", + PREWARM_SESSION_BUDGET + ); + return; + } + std::thread::sleep(PREWARM_SESSION_RECHECK); + } + let t = std::time::Instant::now(); + schedule_drm_cache_refresh(); + match scrap::drm_reader::DrmReader::open(None, 0) { + Some(mut r) => { + // grab_desc(), not grab(): exports an fd without loading libEGL into the root service. + if let Ok((fd, _desc)) = r.grab_desc() { + drop(fd); // close the warm-up fd; we only wanted to prime the device/import path + } + log::info!("drm: pre-warm framebuffer primed in {:?}", t.elapsed()); + } + None => log::info!("drm: pre-warm skipped (no reader; cache refresh requested)"), + } +} + +/// Capture producer in the ROOT `--service`: one task per consumer, reader on a worker thread. +#[tokio::main(flavor = "current_thread")] +pub async fn start_drm() { + match new_drm_listener() { + Ok(mut incoming) => { + if let Err(err) = std::thread::Builder::new() + .name("drm-prewarm".into()) + .spawn(drm_prewarm) + { + log::warn!("drm: could not spawn the pre-warm thread ({err}); skipping the warmup"); + } + if let Err(err) = std::thread::Builder::new() + .name("drm-udev".into()) + .spawn(drm_udev_listener) + { + log::warn!( + "drm: could not spawn the udev listener ({err}); a mid-session topology change \ + will not be pushed, and consumers pick it up on their next handshake" + ); + } + loop { + match incoming.next().await { + Some(Ok(stream)) => { + tokio::spawn(async move { + if let Err(err) = handle_drm_conn(Connection::new(stream)).await { + log::info!("drm ipc connection ended: {}", err); + } + }); + } + Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), + None => { + log::error!("drm ipc listener stream ended; stopping drm producer"); + break; + } + } + } + } + Err(err) => { + log::error!("Failed to start drm ipc server: {}", err); + } + } +} + +const MAX_DRM_CONNS: usize = 8; + +fn drm_conn_admitted(prev_count: usize) -> bool { + prev_count < MAX_DRM_CONNS +} + +const MAX_DRM_AUTH_IN_FLIGHT: usize = 4; + +fn drm_auth_admitted(prev_in_flight: usize) -> bool { + prev_in_flight < MAX_DRM_AUTH_IN_FLIGHT +} + +fn drm_peer_authorized(peer_uid: Option, active_uid: Option) -> bool { + match peer_uid { + Some(0) => true, + Some(uid) => active_uid == Some(uid), + None => false, + } +} + +/// Handle one `_drm` consumer: a private worker thread owns the `!Send` reader; this task forwards. +async fn handle_drm_conn(stream: Connection) -> ResultType<()> { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + // World-connectable socket, so the peer MUST be authorized here (this listener bypasses the + // generic `start()` accept loop). On the blocking pool: a cache miss forks `loginctl`. + static DRM_AUTH_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); + struct DrmAuthGuard; + impl Drop for DrmAuthGuard { + fn drop(&mut self) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_auth_admitted(DRM_AUTH_IN_FLIGHT.fetch_add(1, Ordering::SeqCst)) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + // Deliberately `debug`, not `warn`: this is reachable by any local uid, so a level that + // reaches the service log on every attempt is an unbounded log-write primitive for that peer. + log::debug!("drm: too many _drm authorizations in flight; dropping this connection"); + return Ok(()); + } + let auth_guard = DrmAuthGuard; + let (stream, authorized) = tokio::task::spawn_blocking(move || { + let ok = authorize_service_scoped_ipc_connection(&stream, "_drm"); + (stream, ok) + }) + .await?; + drop(auth_guard); + if !authorized { + // Deliberately no log here: the call above already reports it -- the uid mismatch through + // `log_rejected_service_connection`, throttled to one line per 5 s, and the executable + // mismatch as a plain warn. A second, unthrottled warn here would be the same unbounded + // log-write primitive. + return Ok(()); + } + + static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); + struct DrmConnGuard; + impl Drop for DrmConnGuard { + fn drop(&mut self) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_conn_admitted(DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst)) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); + return Ok(()); + } + let _conn_guard = DrmConnGuard; + + // Re-authorized per frame below: DRM/KMS capture is NOT session-scoped, so unless a stream stops + // when the active session changes the outgoing user's --server keeps receiving the incoming + // user's screen (and the greeter in between). + let peer_uid = stream.peer_uid(); + + let mut conn = dup_to_drm_conn(&stream)?; + drop(stream); + + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<(String, u32, bool)>(); + let stop = Arc::new(AtomicBool::new(false)); + let _stop_guard = DrmStopGuard(stop.clone()); + let worker_stop = stop.clone(); + let frames_gated = Arc::new(AtomicBool::new(false)); + let worker_gate = frames_gated.clone(); + std::thread::Builder::new() + .name("drm-capture".into()) + .spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop, worker_gate)) + .map_err(|err| anyhow::anyhow!("could not spawn the drm capture worker: {err}"))?; + + let displays = match frame_rx.recv().await { + Some(DrmProducerMsg::Displays(d)) => d, + _ => { + log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); + return Ok(()); + } + }; + conn.send_msg(&Data::DrmDisplayList(displays.clone()), None).await?; + + let (display_idx, need_cpu) = match conn.recv_msg_timeout2(10_000).await { + Some(Ok((Data::DrmStart { display, need_cpu }, _fd))) => (display, need_cpu), + Some(Ok((_, _fd))) => { + log::info!("drm: peer sent something other than DrmStart in the handshake; closing"); + return Ok(()); + } + Some(Err(e)) => return Err(e), + None => return Ok(()), // timed out: client never chose a display + }; + // Reject crtc 0: `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams the WRONG monitor. + let selected = usize::try_from(display_idx) + .ok() + .and_then(|i| displays.get(i)); + let target_crtc = selected.map(|d| d.crtc_id).unwrap_or(0); + let target_device = selected.map(|d| d.device.clone()).unwrap_or_default(); + if target_crtc == 0 { + log::warn!( + "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" + ); + return Ok(()); + } + if crtc_tx.send((target_device, target_crtc, need_cpu)).is_err() { + return Ok(()); + } + + let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + const DRM_FRAME_CREDIT: i32 = 2; + let mut credit: i32 = DRM_FRAME_CREDIT; + let mut credit_since = std::time::Instant::now(); + let mut held_frame: Option = None; + loop { + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + // While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog: a + // consumer that stops acking without closing the socket would otherwise hold this connection, + // its worker thread and the privileged DRM context open indefinitely. + const CREDIT_STALL: std::time::Duration = std::time::Duration::from_secs(5); + if credit > 0 { + credit_since = std::time::Instant::now(); + } else if credit_since.elapsed() > CREDIT_STALL { + log::info!("drm: consumer has not acked for {CREDIT_STALL:?}; closing _drm connection"); + break; + } + // This must NOT also require that a frame is already held: those grabs keep the held frame + // fresh (latest-wins below), so gating on "held" would pin whatever frame was in hand when + // credit ran out and ship it stale once the ack lands. + frames_gated.store(credit <= 0, Ordering::Relaxed); + let first: Option = if held_frame.is_some() && credit > 0 { + frame_rx.try_recv().ok() + } else if credit <= 0 { + const CREDIT_POLL: std::time::Duration = std::time::Duration::from_secs(1); + let waited = tokio::time::timeout(CREDIT_POLL, async { + tokio::select! { + biased; + r = conn.wait_readable() => r.map(|_| None), + m = frame_rx.recv() => Ok(Some(m)), + } + }) + .await; + match waited { + Err(_) => None, + Ok(Err(err)) => return Err(err), + Ok(Ok(None)) => None, + Ok(Ok(Some(None))) => break, + Ok(Ok(Some(Some(m)))) => Some(m), + } + } else { + match frame_rx.recv().await { + Some(f) => Some(f), + None => break, + } + }; + // Re-authorize per frame with the CACHE-ONLY active uid: a fresh lookup forks `loginctl` and + // would stall every stream on this single-threaded runtime. A miss is fail-closed for a non-root peer + // (root stays authorized; see `drm_peer_authorized`). + let peer_ok = drm_peer_authorized(peer_uid, active_uid_cached()); + if !peer_ok { + log::warn!("drm: _drm peer no longer matches the active session (or it is unknown); closing"); + break; + } + let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + if gen != seen_gen { + seen_gen = gen; + let fresh = DRM_DISPLAY_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // Send even an EMPTY list, or the consumer keeps advertising removed displays. + conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; + } + let mut latest_frame: Option = held_frame.take(); + let mut msg = first.or_else(|| frame_rx.try_recv().ok()); + while let Some(m) = msg.take() { + match m { + f @ (DrmProducerMsg::Frame { .. } | DrmProducerMsg::FrameCpu { .. }) => { + latest_frame = Some(f); + } + DrmProducerMsg::Cursor { + id, + width, + height, + hotx, + hoty, + colors, + } => { + conn.send_msg( + &Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + }, + None, + ) + .await?; + conn.send_raw(Bytes::from(colors)).await?; + } + DrmProducerMsg::Displays(_) => {} + } + msg = frame_rx.try_recv().ok(); + } + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + if credit <= 0 { + held_frame = latest_frame; + continue; + } + match latest_frame { + Some(DrmProducerMsg::Frame { mut desc, fd }) => { + // Every exported frame carries its fd: the kernel can recycle an fb_id onto another + // buffer with the same geometry/modifier and this side cannot see the dma-buf inode + // that would tell the difference, so eliding it can serve a stale EGLImage. libdrmtap's + // import cache keys on fb_id AND inode, and can only re-import when handed a real fd. + let send_fd = fd.is_some(); + desc.has_fd = send_fd; + let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; + conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; + credit -= 1; // one frame in flight until the consumer acks it + // `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it + // into the peer), which bounds our fd usage to ~1 in flight per frame. + } + Some(DrmProducerMsg::FrameCpu { + width, + height, + data, + }) => { + conn.send_msg(&Data::DrmFrame { width, height }, None).await?; + conn.send_raw(data).await?; + credit -= 1; // one frame in flight until the consumer acks it + } + _ => {} + } + } + Ok(()) +} + +fn drm_capture_worker( + frame_tx: tokio::sync::mpsc::Sender, + crtc_rx: std::sync::mpsc::Receiver<(String, u32, bool)>, + stop: std::sync::Arc, + frames_gated: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + use std::time::Duration; + const FRAME_INTERVAL: Duration = Duration::from_millis(33); + // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5 s). + const MAX_STALLED: u32 = 150; + + let t_conn = std::time::Instant::now(); + + // Enumerate FRESH rather than serve the cache: a cached display may no longer be driven. + let displays = drm_enumerate_settled("a consumer connected"); + if frame_tx + .blocking_send(DrmProducerMsg::Displays(displays)) + .is_err() + { + return; + } + + let (target_device, target_crtc, need_cpu) = match crtc_rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + let device_arg = if target_device.is_empty() { + None + } else { + Some(target_device.as_str()) + }; + let t_open = std::time::Instant::now(); + let mut reader = match scrap::drm_reader::DrmReader::open(device_arg, target_crtc) { + Some(r) => r, + None => { + log::warn!( + "drm: failed to open crtc {target_crtc} on {}; closing _drm connection", + if target_device.is_empty() { "auto" } else { &target_device } + ); + schedule_drm_cache_refresh(); + return; + } + }; + schedule_drm_cache_refresh(); + log::debug!( + "drm: capture reader for crtc {target_crtc} opened in {:?}", + t_open.elapsed() + ); + + static DRM_CONN_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed); + + let mut use_dmabuf = !need_cpu; + + let mut last_cursor_id: u64 = 0; + let mut stalled: u32 = 0; + let mut logged_first = false; + while !stop.load(Ordering::Relaxed) { + let grabbed: Option> = if frames_gated.load(Ordering::Relaxed) + { + // `stalled` is left untouched because the device is healthy -- the task bounds this + // state itself (CREDIT_STALL) since our watchdog cannot advance. + None + } else if use_dmabuf { + Some(match reader.grab_desc() { + Ok((fd, d)) => Ok(DrmProducerMsg::Frame { + desc: DmabufDesc { + buffer_id: (d.fb_id as u64) | ((conn_epoch as u64) << 32), + width: d.width, + height: d.height, + format: d.format, + modifier: d.modifier, + fb_id: d.fb_id, + num_planes: d.num_planes, + offsets: d.offsets, + pitches: d.pitches, + hdr_eotf: d.hdr_eotf, + hdr_max_nits: d.hdr_max_nits, + has_fd: true, // every exported frame carries its fd; see the send below + }, + fd: Some(fd), + }), + Err(err) => Err(err), + }) + } else { + Some(match reader.grab() { + Ok((buf, w, h)) => Ok(DrmProducerMsg::FrameCpu { + width: w as u32, + height: h as u32, + data: Bytes::copy_from_slice(buf), + }), + Err(err) => Err(err), + }) + }; + match grabbed { + None => {} + Some(Ok(msg)) => { + stalled = 0; + if !logged_first { + logged_first = true; + log::debug!( + "drm: first frame for crtc {target_crtc} in {:?} ({} path)", + t_conn.elapsed(), + if use_dmabuf { "dma-buf" } else { "cpu" } + ); + } + if frame_tx.blocking_send(msg).is_err() { + break; + } + } + Some(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => { + stalled += 1; + if stalled > MAX_STALLED { + log::info!("drm: capture stalled (no frame); closing _drm connection"); + break; + } + std::thread::sleep(FRAME_INTERVAL); + continue; + } + Some(Err(err)) if use_dmabuf && err.kind() == std::io::ErrorKind::Unsupported => { + log::warn!( + "drm: grab_desc unsupported ({err}); switching to CPU-mapped fallback for this connection" + ); + use_dmabuf = false; + logged_first = false; + // The stall counter measured the abandoned path; give the fallback the whole budget. + stalled = 0; + continue; + } + Some(Err(err)) => { + log::warn!("drm: capture error: {err}; closing _drm connection"); + break; + } + } + + // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). + if let Some(c) = reader.cursor() { + if c.id != last_cursor_id { + last_cursor_id = c.id; + if frame_tx + .blocking_send(DrmProducerMsg::Cursor { + id: c.id, + width: c.width, + height: c.height, + hotx: c.hotx, + hoty: c.hoty, + colors: c.colors, + }) + .is_err() + { + break; + } + } + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + +/// Ancillary-fd transport for `_drm`: `Framed`/`BytesCodec` cannot carry an SCM_RIGHTS cmsg, so the +/// messages and raw bodies use a 4-byte big-endian length + payload, with any fd bound to the first + /// byte. The reverse-direction frame acks are bare bytes, not framed. +pub(crate) struct DrmConn { + stream: tokio::net::UnixStream, + read_buf: Vec, + /// Set once the current read consumed a byte: a spurious `readable()` vs a mid-frame stall. + consumed: bool, +} + +const MAX_DRM_JSON_BYTES: usize = 8 * 1024 * 1024; +const DRM_BODY_TIMEOUT_MS: u64 = 5_000; +const DRM_SEND_TIMEOUT_MS: u64 = 5_000; + +const MAX_DRM_RAW_BYTES: usize = 512 * 1024 * 1024; +/// `CMSG_SPACE(sizeof(int))` is 24 bytes on our targets; 64 gives headroom and the `align(8)` +/// matches `cmsghdr` alignment. +const DRM_CMSG_CAP: usize = 64; + +/// Aligned storage for the SCM_RIGHTS control buffer (`msg_control` must be `cmsghdr`-aligned). +#[repr(align(8))] +struct DrmCmsgBuf([u8; DRM_CMSG_CAP]); + +/// One non-blocking `sendmsg`; the cmsg is attached ONLY when a fd is present (-1 fails the call). +/// SAFETY: `fd` a valid open socket fd, `buf` a readable slice, `pass_fd` (if any) a valid open fd. +unsafe fn drm_sendmsg(fd: RawFd, buf: &[u8], pass_fd: Option) -> std::io::Result { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + if let Some(sfd) = pass_fd { + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + if cmsg.is_null() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: CMSG_FIRSTHDR null", + )); + } + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + let sfd_c: libc::c_int = sfd; + std::ptr::copy_nonoverlapping( + &sfd_c as *const libc::c_int as *const u8, + libc::CMSG_DATA(cmsg), + std::mem::size_of::(), + ); + } + let n = libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL); + if n < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +/// One non-blocking `recvmsg`: keeps at most one SCM_RIGHTS fd (surplus closed), rejects MSG_CTRUNC. +/// SAFETY: `fd` must be a valid open socket fd; `buf` a valid writable slice. +unsafe fn drm_recvmsg(fd: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Option)> { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = cbuf.0.len() as _; + let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + let mut got: Option = None; + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(cmsg); + let hdr = libc::CMSG_LEN(0) as usize; + let payload = ((*cmsg).cmsg_len as usize).saturating_sub(hdr); + let count = payload / std::mem::size_of::(); + for i in 0..count { + let mut rawfd: libc::c_int = -1; + std::ptr::copy_nonoverlapping( + data.add(i * std::mem::size_of::()), + &mut rawfd as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + if rawfd >= 0 { + let owned = OwnedFd::from_raw_fd(rawfd); + if got.is_none() { + got = Some(owned); + } // else: surplus fd, dropped here -> closed + } + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + drop(got); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: truncated SCM_RIGHTS control message (MSG_CTRUNC)", + )); + } + Ok((n as usize, got)) +} + +async fn drm_write_all( + stream: &tokio::net::UnixStream, + mut buf: &[u8], + mut pass_fd: Option, +) -> ResultType<()> { + // ONE deadline for the whole write: arming it per readiness wait lets a dripping peer re-arm it. + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + while !buf.is_empty() { + match tokio::time::timeout_at(deadline, stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: peer did not accept the remaining {} byte(s) within {DRM_SEND_TIMEOUT_MS}ms; closing", + buf.len() + ), + } + let raw = stream.as_raw_fd(); + let chunk = buf; + let fd_now = pass_fd; + match stream.try_io(tokio::io::Interest::WRITABLE, || unsafe { + drm_sendmsg(raw, chunk, fd_now) + }) { + Ok(0) => bail!("drm: socket write returned 0 (peer closed)"), + Ok(n) => { + pass_fd = None; // ancillary delivered with these bytes; do not re-send it + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + +async fn drm_send_frame( + stream: &tokio::net::UnixStream, + payload: &[u8], + pass_fd: Option, +) -> ResultType<()> { + if payload.len() > u32::MAX as usize { + bail!("drm: frame too large ({} bytes)", payload.len()); + } + let prefix = (payload.len() as u32).to_be_bytes(); + drm_write_all(stream, &prefix, pass_fd).await?; + drm_write_all(stream, payload, None).await?; + Ok(()) +} + +async fn drm_read_full( + stream: &tokio::net::UnixStream, + buf: &mut [u8], + want_cmsg: bool, + progress: &mut bool, +) -> ResultType> { + use hbb_common::libc; + let mut off = 0usize; + let mut got: Option = None; + while off < buf.len() { + stream.readable().await?; + let raw = stream.as_raw_fd(); + let use_cmsg = want_cmsg && got.is_none(); + let n = { + let dst: &mut [u8] = &mut buf[off..]; + match stream.try_io(tokio::io::Interest::READABLE, move || unsafe { + if use_cmsg { + drm_recvmsg(raw, dst) + } else { + let m = libc::read(raw, dst.as_mut_ptr() as *mut libc::c_void, dst.len()); + if m < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok((m as usize, None)) + } + } + }) { + Ok((0, _fd)) => bail!("drm: socket closed by peer"), + Ok((m, fd)) => { + if let Some(f) = fd { + if got.is_none() { + got = Some(f); + } + } + m + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + }; + // Any byte off the socket commits us to this frame: a cancellation cannot be re-polled. + if n > 0 { + *progress = true; + } + off += n; + } + Ok(got) +} + +impl DrmConn { + pub fn new(stream: tokio::net::UnixStream) -> Self { + Self { + stream, + read_buf: Vec::new(), + consumed: false, + } + } + + pub async fn send_msg(&mut self, data: &Data, fd: Option>) -> ResultType<()> { + let payload = serde_json::to_vec(data)?; + let pass_fd = fd.map(|f| f.as_raw_fd()); + drm_send_frame(&self.stream, &payload, pass_fd).await + } + + pub async fn send_frame_ack(&self) -> ResultType<()> { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + loop { + match tokio::time::timeout_at(deadline, self.stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: _drm frame-ack was not accepted within {DRM_SEND_TIMEOUT_MS}ms; closing" + ), + } + match self.stream.try_write(&[1u8]) { + Ok(n) if n > 0 => return Ok(()), + Ok(_) => bail!("drm: _drm frame-ack write returned 0 (peer closed)"), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + } + + pub fn drain_frame_acks(&self, credit: &mut i32, max: i32) -> ResultType<()> { + let mut buf = [0u8; 64]; + // BOUNDED: "until WouldBlock" is the peer's promise; a continuous writer would pin us. + const MAX_ACK_READS: usize = 64; + for _ in 0..MAX_ACK_READS { + match self.stream.try_read(&mut buf) { + Ok(0) => bail!("drm: _drm frame-ack peer closed"), + Ok(n) => { + *credit = (*credit + n as i32).min(max); + if *credit >= max { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + pub async fn wait_readable(&self) -> ResultType<()> { + self.stream.readable().await?; + Ok(()) + } + + pub async fn recv_msg(&mut self) -> ResultType<(Data, Option)> { + self.consumed = false; + let mut prefix = [0u8; 4]; + let fd = drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed).await?; + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_JSON_BYTES { + bail!("drm: message length {len} exceeds cap {MAX_DRM_JSON_BYTES}"); + } + if self.read_buf.len() < len { + self.read_buf.resize(len, 0); + } + drm_read_full(&self.stream, &mut self.read_buf[..len], false, &mut self.consumed).await?; + let data: Data = serde_json::from_slice(&self.read_buf[..len])?; + Ok((data, fd)) + } + + /// Cancel-safe timeout wrapper around `recv_msg`. `None` = nothing consumed, so re-polling is + /// safe; past the first byte the frame is committed and an overrun is a hard error. + pub async fn recv_msg_timeout2( + &mut self, + ms_timeout: u64, + ) -> Option)>> { + let ready = timeout(ms_timeout, self.stream.readable()).await; + match ready { + Err(_) => None, // no frame started: clean boundary, caller re-checks `stop` + Ok(Err(e)) => Some(Err(e.into())), + Ok(Ok(())) => match timeout(ms_timeout, self.recv_msg()).await { + Ok(res) => Some(res), + Err(_) if self.consumed => Some(Err(anyhow::anyhow!( + "drm: frame body stalled past {ms_timeout}ms after first byte; closing" + ))), + Err(_) => None, + }, + } + } + + pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { + drm_send_frame(&self.stream, &data, None).await + } + + pub async fn next_raw_into(&mut self, out: &mut Vec) -> ResultType<()> { + match timeout(DRM_BODY_TIMEOUT_MS, self.next_raw_into_unbounded(out)).await { + Ok(res) => res, + Err(_) => bail!( + "drm: raw body did not arrive within {DRM_BODY_TIMEOUT_MS}ms of its header; closing" + ), + } + } + + async fn next_raw_into_unbounded(&mut self, out: &mut Vec) -> ResultType<()> { + let mut prefix = [0u8; 4]; + if drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed) + .await? + .is_some() + { + log::warn!("drm: unexpected fd on a raw-body frame; dropping"); + } + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_RAW_BYTES { + bail!("drm: raw body length {len} exceeds cap {MAX_DRM_RAW_BYTES}"); + } + out.resize(len, 0); + drm_read_full(&self.stream, &mut out[..], false, &mut self.consumed).await?; + Ok(()) + } +} + +#[cfg(test)] +mod drm_conn_tests { + use super::*; + use hbb_common::libc; + use hbb_common::tokio::{self, io::AsyncWriteExt}; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + + // Added to the wire later: an older peer's message must still decode. + #[test] + fn drm_display_info_decodes_without_render_node() { + let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0, + "width":3840,"height":2160,"active":true}"#; + let info: DrmDisplayInfo = + serde_json::from_str(legacy).expect("a pre-render_node payload must still decode"); + assert_eq!(info.name, "DP-1"); + assert_eq!(info.crtc_id, 386); + assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node"); + assert!(info.device.is_empty(), "missing device means auto-detect"); + + let current = DrmDisplayInfo { + name: "DP-1".to_owned(), + crtc_id: 386, + x: 0, + y: 0, + width: 3840, + height: 2160, + active: true, + render_node: "/dev/dri/renderD129".to_owned(), + device: "/dev/dri/card2".to_owned(), + }; + let wire = serde_json::to_vec(¤t).unwrap(); + let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap(); + assert_eq!(back, current); + } + + fn pipe() -> (OwnedFd, OwnedFd) { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } + } + + unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { + let mut iov = libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }; + let fdbytes = fds.len() * std::mem::size_of::(); + let space = libc::CMSG_SPACE(fdbytes as u32) as usize; + let mut cbuf = vec![0u8; space]; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = space as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; + std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); + libc::sendmsg(sock, &msg, 0) + } + + #[tokio::test] + async fn roundtrip_msg_no_fd() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) + .await + .unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 1920, + height: 1080 + } + )); + assert!(fd.is_none(), "no fd was sent, none must be reported"); + } + + #[tokio::test] + async fn roundtrip_msg_with_fd_identity() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) + .await + .unwrap(); + let (_data, fd) = rx.recv_msg().await.unwrap(); + let recv_fd = fd.expect("an fd was attached, it must be received"); + let sentinel = [0xABu8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); + } + + #[tokio::test] + async fn roundtrip_raw_body() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let body = Bytes::from(vec![7u8; 5000]); + tx.send_raw(body.clone()).await.unwrap(); + let mut got = Vec::new(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &body[..]); + let short = Bytes::from(vec![9u8; 10]); + tx.send_raw(short.clone()).await.unwrap(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &short[..]); + } + + #[tokio::test] + async fn rejects_oversized_length_prefix() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); + a.write_all(&bogus).await.unwrap(); + let err = rx + .recv_msg() + .await + .err() + .expect("a length past the cap must be rejected"); + assert!( + err.to_string().contains("exceeds cap"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_body_that_never_arrives_times_out() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + a.write_all(&10u32.to_be_bytes()).await.unwrap(); + let mut got = Vec::new(); + let err = rx + .next_raw_into(&mut got) + .await + .err() + .expect("a body that never arrives must time out"); + assert!( + err.to_string().contains("did not arrive"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_dripping_peer_cannot_re_arm_the_send_deadline() { + use tokio::io::AsyncReadExt; + let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap(); + let payload = vec![0u8; 32 * 1024 * 1024]; + // Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms. + let drip = tokio::spawn(async move { + let mut sink = vec![0u8; 64 * 1024]; + loop { + if reader.read(&mut sink).await.unwrap_or(0) == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }); + let started = std::time::Instant::now(); + let outcome = tokio::time::timeout( + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4), + drm_write_all(&writer, &payload, None), + ) + .await; + drip.abort(); + let inner = outcome.expect( + "the send deadline did not fire: the budget is being re-armed per readiness wait", + ); + let err = inner.err().expect("a dripping peer must not complete the write"); + assert!( + err.to_string().contains("did not accept the remaining"), + "unexpected error: {err}" + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3), + "took {:?}, which is not the send deadline firing", + started.elapsed() + ); + } + + #[tokio::test] + async fn surplus_fds_keep_only_the_first() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + let (rd2, _wr2) = pipe(); + let payload = serde_json::to_vec(&Data::DrmFrame { + width: 8, + height: 8, + }) + .unwrap(); + let prefix = (payload.len() as u32).to_be_bytes(); + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + a.write_all(&payload).await.unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 8, + height: 8 + } + )); + let kept = fd.expect("the first surplus fd must be kept"); + let sentinel = [0x5Au8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); + } + + // 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC. + #[tokio::test] + async fn rejects_truncated_control_message() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, _wr) = pipe(); + let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); + let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); + let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + let err = rx + .recv_msg() + .await + .err() + .expect("a truncated control message must be rejected"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("truncat") || msg.contains("ctrunc"), + "unexpected error: {err}" + ); + } + + #[test] + fn peer_uid_from_fd_reads_socket_peer() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + let euid = unsafe { libc::geteuid() }; + assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); + } + + #[test] + fn drm_peer_authorized_matrix() { + assert!(drm_peer_authorized(Some(0), Some(1000))); + assert!(drm_peer_authorized(Some(0), None)); + assert!(drm_peer_authorized(Some(1000), Some(1000))); + assert!(!drm_peer_authorized(Some(1000), Some(1001))); + assert!(!drm_peer_authorized(Some(1000), None)); + assert!(!drm_peer_authorized(None, Some(1000))); + assert!(!drm_peer_authorized(None, None)); + } + + #[test] + fn accept_time_exe_match_accepts_only_our_own_executable() { + let me = std::process::id(); + assert!( + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(me), "_drm").is_ok(), + "the test process must match its own executable" + ); + + let mut other = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("/bin/sleep should be spawnable in the test environment"); + // Until the child finishes exec'ing, /proc//exe still points at OUR binary. + let ours = std::fs::read_link(format!("/proc/{me}/exe")).ok(); + let peer_link = format!("/proc/{}/exe", other.id()); + let mut exec_done = false; + for _ in 0..200 { + match std::fs::read_link(&peer_link) { + Ok(p) if Some(&p) != ours.as_ref() => { + exec_done = true; + break; + } + _ => std::thread::sleep(std::time::Duration::from_millis(10)), + } + } + let res = if exec_done { + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(other.id()), "_drm") + } else { + Err(anyhow::anyhow!("child never exec'd; nothing was tested")) + }; + let _ = other.kill(); + let _ = other.wait(); + assert!(exec_done, "the spawned child never exec'd, so the negative case was not exercised"); + assert!( + res.is_err(), + "a peer running another executable must be rejected, got {res:?}" + ); + + assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err()); + } + + #[test] + fn drm_conn_admission_bound() { + assert!(drm_conn_admitted(0)); + assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot + assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected + assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected + } + + #[test] + fn drm_auth_admission_bound() { + assert!(drm_auth_admitted(0)); + assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected + assert!( + MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS, + "the pre-auth bound must not be looser than the connection cap" + ); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index e0157f3a943..2472ecc83cd 100644 --- a/src/ipc/fs.rs +++ b/src/ipc/fs.rs @@ -164,9 +164,25 @@ fn scrub_preexisting_ipc_parent_entries( Ok(()) } -fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { - let path = config::Config::ipc_path(postfix); - let parent_dir = Path::new(&path) +/// Remove one entry from the IPC parent directory through a no-follow fd on that directory. +/// +/// Prefer this over `std::fs::remove_file` for anything about to be bound: `remove_file` is +/// `unlink(2)`, which returns EISDIR against a directory-typed squatter and leaves it in place, +/// and the bind that follows then fails EADDRINUSE. `remove_parent_entry_via_fd` fstats the +/// entry first and picks `AT_REMOVEDIR` when it needs to. +/// +/// `AT_REMOVEDIR` is `rmdir(2)`, so the directory case this closes is the EMPTY one; a non-empty +/// squatter still yields ENOTEMPTY and still blocks the bind that follows. That is deliberate, and +/// the "obvious" fix is worse than the bug: removing it recursively would be root deleting a tree +/// an unprivileged process planted. What the caller gains there is a named error to log ahead of +/// the bind's own failure, not a successful bind. +pub(crate) fn remove_ipc_entry_via_secure_parent_fd(path: &str) -> ResultType<()> { + let entry_name = Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))? + .to_owned(); + let parent_dir = Path::new(path) .parent() .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; @@ -179,8 +195,8 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { return Err(Error::new( open_err.kind(), format!( - "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", - postfix, + "failed to open ipc parent dir for stale socket cleanup (no-follow): path={}, parent={}, err={}", + path, parent_dir.display(), open_err ), @@ -189,7 +205,11 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { } }; let _fd_guard = FdGuard(fd); - remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) + remove_parent_entry_via_fd(fd, parent_dir, &entry_name) +} + +fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { + remove_ipc_entry_via_secure_parent_fd(&config::Config::ipc_path(postfix)) } // Purpose: @@ -686,6 +706,64 @@ pub(crate) fn should_scrub_parent_entries_after_check_pid( #[cfg(test)] mod tests { + // Pins the HELPER's contract, which is all `new_drm_listener` consists of at that line -- not + // the call site itself. Binding the real `/tmp/-service/ipc_drm` from a test would collide + // with a live root service, so "the listener still calls this" is not covered here. + #[test] + fn test_remove_ipc_entry_via_secure_parent_fd_clears_an_empty_directory_squatter() { + let unique = format!( + "rustdesk-ipc-entry-remove-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let squatter = base.join("ipc_drm"); + std::fs::create_dir(&squatter).unwrap(); + + // Positive control for the defect this closes: `remove_file` is `unlink(2)` and cannot + // remove a directory. That is why the listener could not clear one, and then failed to + // bind over it. Without this line a passing test would prove nothing. + assert!( + std::fs::remove_file(&squatter).is_err(), + "remove_file must fail on a directory, or this test is vacuous" + ); + assert!(squatter.is_dir()); + + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!( + !squatter.exists(), + "the fd-based removal picks AT_REMOVEDIR and clears it" + ); + + // Idempotent: this runs before every bind, so a path that is already gone is not an error. + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + + // The ORDINARY case, and the one the listener hits on every restart: a stale socket left by + // the previous run, i.e. a regular file. Covered here because the other file-removal test + // goes through `remove_parent_entry_via_fd` and the postfix path, not this entry point. + std::fs::write(&squatter, b"stale").unwrap(); + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!(!squatter.exists(), "a stale regular file is cleared too"); + + // And the documented limit, pinned so the doc cannot drift: AT_REMOVEDIR is rmdir(2), so a + // NON-empty squatter is reported, not cleared. The caller logs that and carries on; nothing + // here should ever start deleting a tree it did not create. + std::fs::create_dir(&squatter).unwrap(); + std::fs::write(squatter.join("planted"), b"x").unwrap(); + assert!( + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()) + .is_err(), + "a non-empty directory must be reported, not silently left as success" + ); + assert!(squatter.join("planted").exists(), "and not deleted"); + + std::fs::remove_dir_all(&base).ok(); + } + #[test] fn test_write_pid_file_rejects_symlink() { use std::os::unix::fs::symlink; diff --git a/src/keyboard.rs b/src/keyboard.rs index e0466970e91..3b6e57beaaa 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -2,7 +2,7 @@ use crate::flutter; #[cfg(target_os = "windows")] use crate::platform::windows::{get_char_from_vk, get_unicode_from_vk}; -#[cfg(not(any(feature = "flutter", feature = "cli")))] +#[cfg(not(feature = "flutter"))] use crate::ui::CUR_SESSION; use crate::ui_session_interface::{InvokeUiSession, Session}; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -469,7 +469,7 @@ static mut IS_LEFT_OPTION_DOWN: bool = false; #[cfg(not(any(target_os = "android", target_os = "ios")))] fn get_keyboard_mode() -> String { - #[cfg(not(any(feature = "flutter", feature = "cli")))] + #[cfg(not(feature = "flutter"))] if let Some(session) = CUR_SESSION.lock().unwrap().as_ref() { return session.get_keyboard_mode(); } @@ -991,7 +991,7 @@ pub fn event_to_key_events( } pub fn send_key_event(key_event: &KeyEvent) { - #[cfg(not(any(feature = "flutter", feature = "cli")))] + #[cfg(not(feature = "flutter"))] if let Some(session) = CUR_SESSION.lock().unwrap().as_ref() { session.send_key_event(key_event); } @@ -1003,7 +1003,7 @@ pub fn send_key_event(key_event: &KeyEvent) { } pub fn get_peer_platform() -> String { - #[cfg(not(any(feature = "flutter", feature = "cli")))] + #[cfg(not(feature = "flutter"))] if let Some(session) = CUR_SESSION.lock().unwrap().as_ref() { return session.peer_platform(); } diff --git a/src/lan.rs b/src/lan.rs index 38c31adf925..2a648aab04b 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -241,6 +241,14 @@ fn wait_response( Some(rendezvous_message::Union::PeerDiscovery(p)) => { last_recv_time = Instant::now(); if p.cmd == "pong" { + if !crate::common::is_valid_untrusted_peer_id(&p.id) { + log::warn!( + "Ignoring LAN discovery response from {} with invalid peer id", + addr + ); + continue; + } + let local_mac = if try_get_ip_by_peer { if let Some(self_addr) = get_ipaddr_by_peer(&addr) { get_mac(&self_addr) diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2b7e8d6873d..f66beca8f97 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "شخص ما فعل وضع الخصوصية, خروج"), ("Unsupported", "غير مدعوم"), ("Peer denied", "القرين رفض"), - ("Please install plugins", "الرجاء تثبيت الاضافات"), ("Peer exit", "خروج القرين"), ("Failed to turn off", "فشل ايقاف التشغيل"), ("Turned off", "مطفئ"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "البصمة"), ("Copy Fingerprint", "نسخ البصمة"), ("no fingerprints", "لا توجد بصمات اصابع"), - ("Select a peer", "اختر قرين"), - ("Select peers", "اختر الاقران"), - ("Plugins", "الاضافات"), - ("Uninstall", "الغاء التثبيت"), ("Update", "تحديث"), - ("Enable", "تفعيل"), - ("Disable", "تعطيل"), - ("Options", "الخيارات"), ("resolution_original_tip", "الدقة الأصلية"), ("resolution_fit_local_tip", "تناسب الدقة المحلية"), ("resolution_custom_tip", "دقة مخصصة"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "الإظهار على شريط الأدوات المُصغّر"), ("All monitors", "جميع الشاشات"), ("#{} monitor", "الشاشة رقم {}"), + ("conn-e2ee-unavailable-tip", "تعذر التحقق من التشفير من طرف إلى طرف.\nقد يكون الجهاز البعيد ما يزال قيد الإعداد. حاول مرة أخرى لاحقًا.\nإذا استمر حدوث ذلك، فقد يكون الخادم غير موثوق به.\nهل تريد المتابعة على أي حال؟"), + ("ID whitelisting", "القائمة البيضاء للمعرفات"), + ("Use ID whitelisting", "استخدام القائمة البيضاء للمعرفات"), + ("id_whitelist_tip", "فقط المعرفات في القائمة البيضاء تستطيع الوصول لي"), + ("id_whitelist_wildcard_tip", "أحرف البدل مدعومة: '*' يطابق أي عدد من الأحرف، '?' يطابق حرفاً واحداً بالضبط"), + ("Invalid ID", "معرف غير صحيح"), + ("Your ID is blocked by the peer", "تم حظر معرفك من قبل الطرف الآخر"), + ("Your ip is blocked by the peer", "تم حظر عنوان IP الخاص بك من قبل الطرف الآخر"), + ("id_whitelist_caveat_tip", "يتم الإبلاغ عن المعرف من قبل العميل المتصل. القائمة البيضاء تقلل من التعرض ولا تغني عن كلمة المرور أو 2FA"), + ("whitelist_cidr_tip", "يتم دعم صيغة CIDR، مثال: 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index d417a448ee8..411ea6ec54b 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Хтосьці ўключыў рэжым канфідэнцыйнасці, выхад"), ("Unsupported", "Не падтрымліваецца"), ("Peer denied", "Забаронена абанентам"), - ("Please install plugins", "Усталюйце ўбудовы"), ("Peer exit", "Абанент выйшаў"), ("Failed to turn off", "Немагчыма выключыць"), ("Turned off", "Выключаны"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Адбітак"), ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Select a peer", "Выберыце абанента"), - ("Select peers", "Выберыце абанентаў"), - ("Plugins", "Убудовы"), - ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), - ("Enable", "Уключыць"), - ("Disable", "Адключыць"), - ("Options", "Параметры"), ("resolution_original_tip", "Арыгінальная раздзяляльнасць"), ("resolution_fit_local_tip", "Супадзенне з лакальнай раздзяляльнасцю"), ("resolution_custom_tip", "Карыстацкая раздзяляльнасць"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Паказваць на згорнутай панэлі інструментаў"), ("All monitors", "Усе манітори"), ("#{} monitor", "Манітор {}"), + ("conn-e2ee-unavailable-tip", "Не ўдалося праверыць скразное шыфраванне.\nАддаленая прылада, магчыма, яшчэ наладжваецца. Паспрабуйце пазней.\nКалі гэта будзе паўтарацца, сервер можа быць ненадзейным.\nУсё роўна працягнуць?"), + ("ID whitelisting", "Спіс дазволеных ID"), + ("Use ID whitelisting", "Выкарыстоўваць белы спіс ID"), + ("id_whitelist_tip", "Атрымліваць доступ да маёй прылады могуць толькі ID з белага спісу."), + ("id_whitelist_wildcard_tip", "Падтрымліваюцца падстаноўныя знакі: '*' адпавядае любой колькасці сімвалаў, '?' — роўна аднаму сімвалу"), + ("Invalid ID", "Няправільны ID"), + ("Your ID is blocked by the peer", "Ваш ID заблакаваны аддаленай прыладай"), + ("Your ip is blocked by the peer", "Ваш IP-адрас заблакаваны аддаленай прыладай"), + ("id_whitelist_caveat_tip", "ID паведамляецца кліентам, які падключаецца. Белы спіс памяншае паверхню атакі і не замяняе пароль або 2FA"), + ("whitelist_cidr_tip", "Падтрымліваецца натацыя CIDR, напрыклад: 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 0f729342694..b56334f6d95 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Някой включва режим на поверителност, изход"), ("Unsupported", "Неподдържан"), ("Peer denied", "Отказ от другата страна"), - ("Please install plugins", "Моля поставете плъгини"), ("Peer exit", "Изход от другата страна"), ("Failed to turn off", "Неуспешен опит за изключване"), ("Turned off", "Изкключен"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Пръстов отпечатък"), ("Copy Fingerprint", "Копиране на пръстов отпечатък"), ("no fingerprints", "Няма пръстови отпечатъци"), - ("Select a peer", "Избери отдалечена страна"), - ("Select peers", "Избери отдалечени страни"), - ("Plugins", "Плъгини"), - ("Uninstall", "Премахни"), ("Update", "Обновяване"), - ("Enable", "Позволяване"), - ("Disable", "Забрана"), - ("Options", "Настроики"), ("resolution_original_tip", "Оригинална разделителна способност"), ("resolution_fit_local_tip", "Приспособяване към тукашната разделителна способност"), ("resolution_custom_tip", "Разделителна способност по свой избор"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Показване в минимизираната лента с инструменти"), ("All monitors", "Всички монитори"), ("#{} monitor", "Монитор {}"), + ("conn-e2ee-unavailable-tip", "Шифроването от край до край не може да бъде проверено.\nОтдалеченото устройство може все още да се настройва. Опитайте отново по-късно.\nАко това продължи, сървърът може да не е надежден.\nДа се продължи ли въпреки това?"), + ("ID whitelisting", "Позволени ID"), + ("Use ID whitelisting", "Използване бял списък с ID"), + ("id_whitelist_tip", "Само ID от белия списък имат достъп до мен"), + ("id_whitelist_wildcard_tip", "Поддържат се заместващи символи: '*' съответства на произволен брой знаци, '?' — на точно един знак"), + ("Invalid ID", "Невалидно ID"), + ("Your ID is blocked by the peer", "Вашето ID е блокирано от отсрещната страна"), + ("Your ip is blocked by the peer", "Вашият IP адрес е блокиран от отсрещната страна"), + ("id_whitelist_caveat_tip", "ID се съобщава от свързващия се клиент. Белият списък намалява изложеността и не замества паролата или 2FA"), + ("whitelist_cidr_tip", "Поддържа се CIDR нотация, например: 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 4cabf259b78..1412a2b76b5 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "S'ha activat el Mode privat; surt"), ("Unsupported", "No suportat"), ("Peer denied", "Client denegat"), - ("Please install plugins", "Instal·leu els complements"), ("Peer exit", "Finalitzat pel client"), ("Failed to turn off", "Ha fallat en desactivar"), ("Turned off", "Desactivat"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empremta"), ("Copy Fingerprint", "Copia l'empremta"), ("no fingerprints", "Cap empremta"), - ("Select a peer", "Seleccioneu un client"), - ("Select peers", "Seleccioneu els clients"), - ("Plugins", "Complements"), - ("Uninstall", "Desinstal·la"), ("Update", "Actualitza"), - ("Enable", "Activa"), - ("Disable", "Desactiva"), - ("Options", "Opcions"), ("resolution_original_tip", "Resolució original"), ("resolution_fit_local_tip", "Ajusta la resolució local"), ("resolution_custom_tip", "Resolució personalitzada"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Mostra a la barra d’eines minimitzada"), ("All monitors", "Tots els monitors"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "No s'ha pogut verificar el xifratge d'extrem a extrem.\nEl dispositiu remot encara es pot estar configurant. Torneu-ho a provar més tard.\nSi això continua passant, el servidor pot no ser de confiança.\nVoleu continuar igualment?"), + ("ID whitelisting", "ID admesos"), + ("Use ID whitelisting", "Utilitza un llistat d'ID admesos"), + ("id_whitelist_tip", "Només els ID admesos es podran connectar"), + ("id_whitelist_wildcard_tip", "S'admeten comodins: '*' coincideix amb qualsevol nombre de caràcters, '?' amb un sol caràcter"), + ("Invalid ID", "ID no vàlid"), + ("Your ID is blocked by the peer", "El vostre ID està bloquejat per l'altre extrem"), + ("Your ip is blocked by the peer", "La vostra IP està bloquejada per l'altre extrem"), + ("id_whitelist_caveat_tip", "L'ID és informat pel client que es connecta. Aquesta llista blanca redueix l'exposició i no substitueix la contrasenya ni la 2FA"), + ("whitelist_cidr_tip", "S'admet la notació CIDR, per exemple 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index bc12b3ed103..e685554b4f4 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "其他用户使用隐私模式,退出"), ("Unsupported", "不支持"), ("Peer denied", "被控端拒绝"), - ("Please install plugins", "请安装插件"), ("Peer exit", "被控端退出"), ("Failed to turn off", "退出失败"), ("Turned off", "退出"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指纹"), ("Copy Fingerprint", "复制指纹"), ("no fingerprints", "没有指纹"), - ("Select a peer", "选择一个被控端"), - ("Select peers", "选择被控"), - ("Plugins", "插件"), - ("Uninstall", "卸载"), ("Update", "更新"), - ("Enable", "启用"), - ("Disable", "禁用"), - ("Options", "选项"), ("resolution_original_tip", "原始分辨率"), ("resolution_fit_local_tip", "适应本地分辨率"), ("resolution_custom_tip", "自定义分辨率"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "在最小化工具栏上显示"), ("All monitors", "所有显示器"), ("#{} monitor", "{}号显示器"), + ("conn-e2ee-unavailable-tip", "无法验证端到端加密。\n远程设备可能仍在准备中,请稍后重试。\n如果此问题持续出现,服务器可能不受信任。\n仍要继续吗?"), + ("ID whitelisting", "ID 白名单"), + ("Use ID whitelisting", "只允许白名单上的 ID 访问"), + ("id_whitelist_tip", "只有白名单里的 ID 才能访问本机"), + ("id_whitelist_wildcard_tip", "支持通配符:'*' 匹配任意数量的字符,'?' 匹配单个字符"), + ("Invalid ID", "无效 ID"), + ("Your ID is blocked by the peer", "你的 ID 已被对方阻止"), + ("Your ip is blocked by the peer", "你的 IP 已被对方阻止"), + ("id_whitelist_caveat_tip", "ID 由对端客户端上报,白名单用于减少暴露面,不能替代密码或 2FA"), + ("whitelist_cidr_tip", "支持 CIDR 写法,例如 192.168.1.0/24"), + ("Continue", "继续"), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index ec789fdf761..7bf85ec4910 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Někdo zapne režim ochrany soukromí, ukončete ho"), ("Unsupported", "Nepodporováno"), ("Peer denied", "Protistrana odmítla"), - ("Please install plugins", "Nainstalujte si prosím pluginy"), ("Peer exit", "Ukončení protistrany"), ("Failed to turn off", "Nepodařilo se vypnout"), ("Turned off", "Vypnutý"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisk"), ("Copy Fingerprint", "Kopírovat otisk"), ("no fingerprints", "žádný otisk"), - ("Select a peer", "Výběr protistrany"), - ("Select peers", "Vybrat protistrany"), - ("Plugins", "Pluginy"), - ("Uninstall", "Odinstalovat"), ("Update", "Aktualizovat"), - ("Enable", "Povolit"), - ("Disable", "Zakázat"), - ("Options", "Možnosti"), ("resolution_original_tip", "Původní rozlišení"), ("resolution_fit_local_tip", "Přizpůsobit místní rozlišení"), ("resolution_custom_tip", "Vlastní rozlišení"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Zobrazit na minimalizovaném panelu nástrojů"), ("All monitors", "Všechny monitory"), ("#{} monitor", "Monitor č. {}"), + ("conn-e2ee-unavailable-tip", "Nepodařilo se ověřit koncové šifrování.\nVzdálené zařízení se možná stále nastavuje. Zkuste to znovu později.\nPokud se to bude opakovat, server nemusí být důvěryhodný.\nPřesto pokračovat?"), + ("ID whitelisting", "Povolování pouze z daných ID"), + ("Use ID whitelisting", "Použít bílou listinu ID"), + ("id_whitelist_tip", "Přístup je umožněn pouze z ID, nacházejících se na seznamu povolených"), + ("id_whitelist_wildcard_tip", "Jsou podporovány zástupné znaky: '*' odpovídá libovolnému počtu znaků, '?' právě jednomu znaku"), + ("Invalid ID", "Neplatné ID"), + ("Your ID is blocked by the peer", "Vaše ID je protistranou blokováno"), + ("Your ip is blocked by the peer", "Vaše IP adresa je protistranou blokována"), + ("id_whitelist_caveat_tip", "ID je hlášeno připojujícím se klientem. Tento seznam snižuje vystavení a nenahrazuje heslo ani 2FA"), + ("whitelist_cidr_tip", "Je podporován zápis CIDR, například 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 8c2e32193cc..d8d9caaf67b 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Nogen aktiverede privatlivstilstand, afslut"), ("Unsupported", "Ikke understøttet"), ("Peer denied", "Modpart nægtet"), - ("Please install plugins", "Installer venligst plugins"), ("Peer exit", "Modpart-Afslut"), ("Failed to turn off", "Mislykkedes i at lukke ned"), ("Turned off", "Slukket"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeraftryk"), ("Copy Fingerprint", "Kopiér fingeraftryk"), ("no fingerprints", "Ingen fingeraftryk"), - ("Select a peer", "Vælg en peer"), - ("Select peers", "Vælg peers"), - ("Plugins", "Plugins"), - ("Uninstall", "Afinstallér"), ("Update", "Opdatér"), - ("Enable", "Aktivér"), - ("Disable", "Deaktivér"), - ("Options", "Valgmuligheder"), ("resolution_original_tip", "Original skærmopløsning"), ("resolution_fit_local_tip", "Tilpas lokal skærmopløsning"), ("resolution_custom_tip", "Bruger-tilpasset skærmopløsning"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Vis på den minimerede værktøjslinje"), ("All monitors", "Alle skærme"), ("#{} monitor", "Skærm {}"), + ("conn-e2ee-unavailable-tip", "End-to-end-kryptering kunne ikke bekræftes.\nDen eksterne enhed er muligvis stadig ved at blive konfigureret. Prøv igen senere.\nHvis dette fortsætter, er serveren muligvis ikke pålidelig.\nFortsæt alligevel?"), + ("ID whitelisting", "ID whitelisting"), + ("Use ID whitelisting", "Brug ID whitelisting"), + ("id_whitelist_tip", "Kun ID'er på whitelisten kan få adgang til mig"), + ("id_whitelist_wildcard_tip", "Jokertegn understøttes: '*' matcher et vilkårligt antal tegn, '?' matcher præcist ét tegn"), + ("Invalid ID", "Ugyldigt ID"), + ("Your ID is blocked by the peer", "Dit ID er blokeret af modparten"), + ("Your ip is blocked by the peer", "Din IP-adresse er blokeret af modparten"), + ("id_whitelist_caveat_tip", "ID'et rapporteres af den klient, der opretter forbindelse. Whitelisten reducerer eksponeringen og erstatter ikke adgangskode eller 2FA"), + ("whitelist_cidr_tip", "CIDR-notation understøttes, f.eks. 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 451bf1af735..44da8446bec 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Jemand hat den Datenschutzmodus aktiviert, wird beendet …"), ("Unsupported", "Nicht unterstützt"), ("Peer denied", "Die Gegenstelle hat die Verbindung abgelehnt."), - ("Please install plugins", "Bitte installieren Sie Plugins"), ("Peer exit", "Die Gegenstelle hat die Verbindung getrennt."), ("Failed to turn off", "Ausschalten fehlgeschlagen"), ("Turned off", "Ausgeschaltet"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingerabdruck"), ("Copy Fingerprint", "Fingerabdruck kopieren"), ("no fingerprints", "Keine Fingerabdrücke"), - ("Select a peer", "Gegenstelle auswählen"), - ("Select peers", "Gegenstellen auswählen"), - ("Plugins", "Plugins"), - ("Uninstall", "Deinstallieren"), ("Update", "Update"), - ("Enable", "Aktivieren"), - ("Disable", "Deaktivieren"), - ("Options", "Einstellungen"), ("resolution_original_tip", "Originale Auflösung"), ("resolution_fit_local_tip", "Lokale Auflösung anpassen"), ("resolution_custom_tip", "Benutzerdefinierte Auflösung"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "In der minimierten Symbolleiste anzeigen"), ("All monitors", "Alle Bildschirme"), ("#{} monitor", "Bildschirm {}"), + ("conn-e2ee-unavailable-tip", "Ende-zu-Ende-Verschlüsselung konnte nicht verifiziert werden.\nDas entfernte Gerät wird möglicherweise noch eingerichtet. Versuchen Sie es später erneut.\nWenn dies weiterhin auftritt, ist der Server möglicherweise nicht vertrauenswürdig.\nTrotzdem fortfahren?"), + ("ID whitelisting", "ID-Whitelist"), + ("Use ID whitelisting", "ID-Whitelist verwenden"), + ("id_whitelist_tip", "Nur IDs auf der Whitelist können zugreifen."), + ("id_whitelist_wildcard_tip", "Platzhalter werden unterstützt: '*' steht für beliebig viele Zeichen, '?' für genau ein Zeichen"), + ("Invalid ID", "Ungültige ID"), + ("Your ID is blocked by the peer", "Ihre ID wird von der Gegenstelle blockiert"), + ("Your ip is blocked by the peer", "Ihre IP-Adresse wird von der Gegenstelle blockiert"), + ("id_whitelist_caveat_tip", "Die ID wird vom verbindenden Client gemeldet. Die Whitelist verringert die Angriffsfläche und ersetzt weder Passwort noch 2FA."), + ("whitelist_cidr_tip", "Die CIDR-Notation wird unterstützt, z. B. 192.168.1.0/24"), + ("Continue", "Weiter"), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index b1b6ff9229c..d6f96fa3cfd 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Κάποιος ενεργοποιεί τη λειτουργία απορρήτου, έξοδος"), ("Unsupported", "Δεν υποστηρίζεται"), ("Peer denied", "Ο απομακρυσμένος σταθμός έχει απορριφθεί"), - ("Please install plugins", "Παρακαλώ εγκαταστήστε τα πρόσθετα"), ("Peer exit", "Ο απομακρυσμένος σταθμός έχει αποσυνδεθεί"), ("Failed to turn off", "Αποτυχία απενεργοποίησης"), ("Turned off", "Απενεργοποιημένο"), @@ -333,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Secure Connection", "Ασφαλής σύνδεση"), ("Insecure Connection", "Μη ασφαλής σύνδεση"), ("Scale original", "Κλιμάκωση πρωτότυπου"), - ("Scale adaptive", "Προσαρμοσμένη κλίμακα"), + ("Scale adaptive", "Αυτόματη προσαρμογή κλίμακας"), ("General", "Γενικά"), ("Security", "Ασφάλεια"), ("Theme", "Θέμα"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Δακτυλικό αποτύπωμα"), ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), - ("Select a peer", "Επιλέξτε έναν σταθμό"), - ("Select peers", "Επιλέξτε σταθμούς"), - ("Plugins", "Επεκτάσεις"), - ("Uninstall", "Κατάργηση εγκατάστασης"), ("Update", "Ενημέρωση"), - ("Enable", "Ενεργοποίηση"), - ("Disable", "Απενεργοποίηση"), - ("Options", "Επιλογές"), ("resolution_original_tip", "Αρχική ανάλυση"), ("resolution_fit_local_tip", "Προσαρμογή στην τοπική ανάλυση"), ("resolution_custom_tip", "Προσαρμοσμένη ανάλυση"), @@ -709,9 +701,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Supported only in the installed version.", "Υποστηρίζεται μόνο στην εγκατεστημένη έκδοση."), ("elevation_username_tip", "Εισαγάγετε όνομα χρήστη ή τομέα\\όνομα χρήστη"), ("Preparing for installation ...", "Προετοιμασία για εγκατάσταση..."), - ("Show my cursor", "Εμφάνιση του κέρσορα μου"), - ("Scale custom", "Προσαρμοσμένη κλίμακα"), - ("Custom scale slider", "Ρυθμιστικό προσαρμοσμένης κλίμακας"), + ("Show my cursor", "Εμφάνιση του δρομέα μου"), + ("Scale custom", "Κλίμακα χρήστη"), + ("Custom scale slider", "Γραμμή ρύθμισης κλίμακας χρήστη"), ("Decrease", "Μείωση"), ("Increase", "Αύξηση"), ("Show virtual mouse", "Εμφάνιση εικονικού ποντικιού"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Εμφάνιση στην ελαχιστοποιημένη γραμμή εργαλείων"), ("All monitors", "Όλες οι οθόνες"), ("#{} monitor", "Οθόνη {}"), + ("conn-e2ee-unavailable-tip", "Δεν ήταν δυνατή η επαλήθευση της κρυπτογράφησης από άκρο σε άκρο.\nΗ απομακρυσμένη συσκευή μπορεί να ρυθμίζεται ακόμα. Δοκιμάστε ξανά αργότερα.\nΑν αυτό συνεχιστεί, ο διακομιστής μπορεί να μην είναι αξιόπιστος.\nΣυνέχεια παρ' όλα αυτά;"), + ("ID whitelisting", "Λίστα επιτρεπόμενων ID"), + ("Use ID whitelisting", "Χρήση λίστας επιτρεπόμενων ID"), + ("id_whitelist_tip", "Μόνο τα ID της λίστας επιτρεπόμενων έχουν πρόσβαση σε εμένα"), + ("id_whitelist_wildcard_tip", "Υποστηρίζονται χαρακτήρες μπαλαντέρ: το '*' αντιστοιχεί σε οποιονδήποτε αριθμό χαρακτήρων, το '?' σε έναν ακριβώς χαρακτήρα"), + ("Invalid ID", "Μη έγκυρο ID"), + ("Your ID is blocked by the peer", "Το ID σας έχει αποκλειστεί από τον απομακρυσμένο υπολογιστή"), + ("Your ip is blocked by the peer", "Η διεύθυνση IP σας έχει αποκλειστεί από τον απομακρυσμένο υπολογιστή"), + ("id_whitelist_caveat_tip", "Το ID αναφέρεται από τον πελάτη που συνδέεται. Η λίστα επιτρεπόμενων μειώνει την έκθεση και δεν αντικαθιστά τον κωδικό πρόσβασης ή το 2FA"), + ("whitelist_cidr_tip", "Υποστηρίζεται η σημειογραφία CIDR, π.χ. 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index d68361255a9..fcd68a3008f 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -279,5 +279,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("wayland-soft-keyboard-input-label", "Soft keyboard input"), ("wayland-keyboard-input-reset-choice-tip", "Reset keyboard input choice"), ("remember-wayland-keyboard-choice-tip", "Don't ask again for this remote computer"), + ("conn-e2ee-unavailable-tip", "Could not verify end-to-end encryption.\nThe remote device may still be setting up. Try again later.\nIf this keeps happening, the server may be untrusted.\nContinue anyway?"), + ("id_whitelist_tip", "Only whitelisted IDs can access me"), + ("id_whitelist_wildcard_tip", "Wildcards are supported: '*' matches any number of characters, '?' matches exactly one character"), + ("id_whitelist_caveat_tip", "The ID is reported by the connecting client. This whitelist reduces exposure and does not replace the password or 2FA."), + ("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"), + ("Your ip is blocked by the peer", "Your IP is blocked by the peer"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 31890084254..f7048783ca9 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Iu ŝaltas modon privata, Eliro"), ("Unsupported", "Nesubtenata"), ("Peer denied", "Samulo rifuzita"), - ("Please install plugins", "Bonvolu instali kromprogramojn"), ("Peer exit", "Samulo eliras"), ("Failed to turn off", "Malsukcesis malŝalti"), ("Turned off", "Malŝaltita"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingrospuro"), ("Copy Fingerprint", "Kopii fingrospuron"), ("no fingerprints", "Neniuj fingrospuroj"), - ("Select a peer", "Elekti samulon"), - ("Select peers", "Elekti samulojn"), - ("Plugins", "Kromprogramoj"), - ("Uninstall", "Malinstali"), ("Update", "Ĝisdatigi"), - ("Enable", "Ebligi"), - ("Disable", "Malebligi"), - ("Options", "Opcioj"), ("resolution_original_tip", "Originala distingivo"), ("resolution_fit_local_tip", "Adapti al loka distingivo"), ("resolution_custom_tip", "Propra distingivo"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Montri en la minimumigita ilobreto"), ("All monitors", "Ĉiuj monitoroj"), ("#{} monitor", "Monitoro {}"), + ("conn-e2ee-unavailable-tip", "Ne eblis kontroli la fin-al-finan ĉifradon.\nLa fora aparato eble ankoraŭ estas agordata. Provu denove poste.\nSe tio daŭre okazas, la servilo eble estas nefidinda.\nĈu daŭrigi tamen?"), + ("ID whitelisting", "Listo de ID akceptataj"), + ("Use ID whitelisting", "Uzi liston de ID akceptataj"), + ("id_whitelist_tip", "Nur la ID en la blanka listo povas kontroli mian komputilon"), + ("id_whitelist_wildcard_tip", "Ĵokeroj estas subtenataj: '*' kongruas kun iu ajn nombro da signoj, '?' kun ekzakte unu signo"), + ("Invalid ID", "ID nevalida"), + ("Your ID is blocked by the peer", "Via ID estas blokita de la alia flanko"), + ("Your ip is blocked by the peer", "Via IP estas blokita de la alia flanko"), + ("id_whitelist_caveat_tip", "La ID estas raportata de la konektiĝanta kliento. La blanka listo malpliigas la eksponiĝon kaj ne anstataŭas la pasvorton aŭ 2FA"), + ("whitelist_cidr_tip", "La notacio CIDR estas subtenata, ekzemple 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index f820994d713..3285a71e0ea 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguien active el modo privacidad, salga"), ("Unsupported", "No soportado"), ("Peer denied", "Par denegado"), - ("Please install plugins", "Instale complementos"), ("Peer exit", "Par salio"), ("Failed to turn off", "Error al apagar"), ("Turned off", "Apagado"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Huella digital"), ("Copy Fingerprint", "Copiar huella digital"), ("no fingerprints", "sin huellas digitales"), - ("Select a peer", "Seleccionar un par"), - ("Select peers", "Seleccionar pares"), - ("Plugins", "Complementos"), - ("Uninstall", "Desinstalar"), ("Update", "Actualizar"), - ("Enable", "Habilitar"), - ("Disable", "Inhabilitar"), - ("Options", "Opciones"), ("resolution_original_tip", "Resolución original"), ("resolution_fit_local_tip", "Ajustar resolución local"), ("resolution_custom_tip", "Resolución personalizada"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Mostrar en la barra de herramientas minimizada"), ("All monitors", "Todos los monitores"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "No se pudo verificar el cifrado de extremo a extremo.\nEs posible que el dispositivo remoto aún se esté configurando. Inténtelo de nuevo más tarde.\nSi esto sigue ocurriendo, es posible que el servidor no sea de confianza.\n¿Continuar de todos modos?"), + ("ID whitelisting", "IDs admitidos"), + ("Use ID whitelisting", "Usar lista de IDs admitidos"), + ("id_whitelist_tip", "Solo los IDs autorizados pueden conectarse a este escritorio"), + ("id_whitelist_wildcard_tip", "Se admiten comodines: '*' coincide con cualquier número de caracteres, '?' con exactamente un carácter"), + ("Invalid ID", "ID incorrecto"), + ("Your ID is blocked by the peer", "Tu ID está bloqueado por el dispositivo remoto"), + ("Your ip is blocked by the peer", "Tu IP está bloqueada por el dispositivo remoto"), + ("id_whitelist_caveat_tip", "El ID lo comunica el cliente que se conecta. Esta lista blanca reduce la exposición y no sustituye a la contraseña ni al 2FA"), + ("whitelist_cidr_tip", "Se admite la notación CIDR, por ejemplo 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 2dbf0f723a2..a97ad97ff6b 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Keegi lülitab sisse privaatsusrežiimi, välju"), ("Unsupported", "Mittetoetatud"), ("Peer denied", "Partner keeldus"), - ("Please install plugins", "Palun paigalda pluginad"), ("Peer exit", "Partner väljub"), ("Failed to turn off", "Väljalülitamine ebaõnnestus"), ("Turned off", "Väljalülitatud"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sõrmejälg"), ("Copy Fingerprint", "Kopeeri sõrmejälg"), ("no fingerprints", "Sõrmejäljed puuduvad"), - ("Select a peer", "Vali partner"), - ("Select peers", "Vali partnerid"), - ("Plugins", "Pluginad"), - ("Uninstall", "Desinstalli"), ("Update", "Uuenda"), - ("Enable", "Luba"), - ("Disable", "Keela"), - ("Options", "Valikud"), ("resolution_original_tip", "Originaalne eraldusvõime"), ("resolution_fit_local_tip", "Ühita kohaliku eraldusvõimega"), ("resolution_custom_tip", "Kohandatud eraldusvõime"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Näita minimeeritud tööriistaribal"), ("All monitors", "Kõik kuvarid"), ("#{} monitor", "Kuvar {}"), + ("conn-e2ee-unavailable-tip", "Otspunktkrüptimist ei saanud kontrollida.\nKaugseade võib olla veel seadistamisel. Proovige hiljem uuesti.\nKui see jätkub, ei pruugi server olla usaldusväärne.\nKas jätkata siiski?"), + ("ID whitelisting", "ID lubamisloend"), + ("Use ID whitelisting", "Kasuta ID-lubamisloendit"), + ("id_whitelist_tip", "Ainult lubamisloendis ID saab mulle ligi"), + ("id_whitelist_wildcard_tip", "Metamärgid on toetatud: '*' vastab suvalisele arvule märkidele, '?' täpselt ühele märgile"), + ("Invalid ID", "Sobimatu ID"), + ("Your ID is blocked by the peer", "Teine pool on sinu ID blokeerinud"), + ("Your ip is blocked by the peer", "Teine pool on sinu IP-aadressi blokeerinud"), + ("id_whitelist_caveat_tip", "ID edastab ühenduv klient. Lubamisloend vähendab eksponeeritust ega asenda parooli või 2FA-d"), + ("whitelist_cidr_tip", "Toetatud on CIDR-tähistus, näiteks 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index d6211df6f8e..ef534828f89 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Norbaitek pribatutasun modua hasten du, irten"), ("Unsupported", "Ez da onartzen"), ("Peer denied", "Parekidea ukatuta"), - ("Please install plugins", "Mesedez, instalatu plugin hauek"), ("Peer exit", "Parekidea irten da"), ("Failed to turn off", "Itzaltzeak huts egin du"), ("Turned off", "Itzalita"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Hatz-marka"), ("Copy Fingerprint", "Kopiatu hatz-marka"), ("no fingerprints", "hatz-markarik ez"), - ("Select a peer", "Hautatu parekidea"), - ("Select peers", "Hautatu parekideak"), - ("Plugins", "Pluginak"), - ("Uninstall", "Desinstalatu"), ("Update", "Eguneratu"), - ("Enable", "Gaitu"), - ("Disable", "Desgaitu"), - ("Options", "Aukerak"), ("resolution_original_tip", "Jatorrizko bereizmena"), ("resolution_fit_local_tip", "Bereizmen lokala egokitu"), ("resolution_custom_tip", "Bereizmen pertsonalizatua"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Erakutsi minimizatutako tresna-barran"), ("All monitors", "Monitore guztiak"), ("#{} monitor", "{}. monitorea"), + ("conn-e2ee-unavailable-tip", "Ezin izan da muturretik muturrerako enkriptatzea egiaztatu.\nUrruneko gailua oraindik konfiguratzen ari daiteke. Saiatu berriro geroago.\nHonek jarraitzen badu, zerbitzaria fidagaitza izan daiteke.\nHala ere jarraitu?"), + ("ID whitelisting", "Onartutako IDak"), + ("Use ID whitelisting", "Erabili ID onartuen zerrenda"), + ("id_whitelist_tip", "Baimendutako IDak soilik konektatu daitezke mahaigain honetara"), + ("id_whitelist_wildcard_tip", "Komodinak onartzen dira: '*' edozein karaktere kopururekin bat dator, '?' karaktere bakar batekin"), + ("Invalid ID", "ID baliogabea"), + ("Your ID is blocked by the peer", "Beste aldeak zure IDa blokeatu du"), + ("Your ip is blocked by the peer", "Beste aldeak zure IP helbidea blokeatu du"), + ("id_whitelist_caveat_tip", "IDa konektatzen den bezeroak jakinarazten du. Zerrenda honek esposizioa murrizten du eta ez du pasahitza edo 2FA ordezkatzen"), + ("whitelist_cidr_tip", "CIDR notazioa onartzen da, adibidez 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 2e02502e5a0..7b01a1a7b1e 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "اگر شخصی حالت حریم خصوصی را روشن کرد، خارج شوید"), ("Unsupported", "پشتیبانی نشده"), ("Peer denied", "توسط میزبان راه دور رد شد"), - ("Please install plugins", "لطفا افزونه ها را نصب کنید"), ("Peer exit", "میزبان خارج شد"), ("Failed to turn off", "خاموش کردن انجام نشد"), ("Turned off", "خاموش شد"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "\n اثر انگشت"), ("Copy Fingerprint", "کپی کردن اثر انگشت"), ("no fingerprints", "بدون اثر انگشت"), - ("Select a peer", "یک همتا را انتخاب کنید"), - ("Select peers", "همتایان را انتخاب کنید"), - ("Plugins", "پلاگین ها"), - ("Uninstall", "حذف نصب"), ("Update", "به روز رسانی"), - ("Enable", "فعال کردن"), - ("Disable", "غیر فعال کردن"), - ("Options", "گزینه ها"), ("resolution_original_tip", "وضوح اصلی"), ("resolution_fit_local_tip", "متناسب با وضوح محلی"), ("resolution_custom_tip", "وضوح سفارشی"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "نمایش در نوار ابزار کوچک‌شده"), ("All monitors", "همه نمایشگرها"), ("#{} monitor", "نمایشگر {}"), + ("conn-e2ee-unavailable-tip", "رمزنگاری سرتاسری قابل تأیید نیست.\nدستگاه راه دور ممکن است هنوز در حال آماده‌سازی باشد. بعداً دوباره تلاش کنید.\nاگر این مشکل ادامه داشت، سرور ممکن است نامطمئن باشد.\nبا این حال ادامه می‌دهید؟"), + ("ID whitelisting", "لیست شناسه های مجاز"), + ("Use ID whitelisting", "استفاده از لیست شناسه های مجاز"), + ("id_whitelist_tip", "فقط شناسه های مجاز می توانند به این دسکتاپ متصل شوند"), + ("id_whitelist_wildcard_tip", "نویسه های عام پشتیبانی می شوند: '*' با هر تعداد نویسه و '?' دقیقا با یک نویسه مطابقت دارد"), + ("Invalid ID", "شناسه نامعتبر است"), + ("Your ID is blocked by the peer", "شناسه شما توسط طرف مقابل مسدود شده است"), + ("Your ip is blocked by the peer", "نشانی IP شما توسط طرف مقابل مسدود شده است"), + ("id_whitelist_caveat_tip", "شناسه توسط کلاینت متصل شونده گزارش می شود. لیست مجاز سطح در معرض بودن را کاهش می دهد و جایگزین رمز عبور یا 2FA نیست"), + ("whitelist_cidr_tip", "نماد CIDR پشتیبانی می شود، برای مثال 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index c3e42d21417..b4bd3cb5b10 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Yksityisyystila otettu käyttöön, poistutaan"), ("Unsupported", "Ei tuettu"), ("Peer denied", "Vastapuoli hylkäsi pyynnön"), - ("Please install plugins", "Asenna tarvittavat lisäosat"), ("Peer exit", "Vastapuoli sulki yhteyden"), ("Failed to turn off", "Sammutus epäonnistui"), ("Turned off", "Sammutettu"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sormenjälki"), ("Copy Fingerprint", "Kopioi sormenjälki"), ("no fingerprints", "Ei sormenjälkiä"), - ("Select a peer", "Valitse vastapää"), - ("Select peers", "Valitse useita vastapään laitteita"), - ("Plugins", "Laajennukset"), - ("Uninstall", "Poista asennus"), ("Update", "Päivitä"), - ("Enable", "Ota käyttöön"), - ("Disable", "Poista käytöstä"), - ("Options", "Asetukset"), ("resolution_original_tip", "Näytä alkuperäisessä resoluutiossa ilman skaalausta"), ("resolution_fit_local_tip", "Sovita etänäyttö paikalliseen näkymään"), ("resolution_custom_tip", "Käytä mukautettua resoluutiota"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Näytä pienennetyssä työkalurivissä"), ("All monitors", "Kaikki näytöt"), ("#{} monitor", "Näyttö {}"), + ("conn-e2ee-unavailable-tip", "Päästä päähän -salausta ei voitu vahvistaa.\nEtälaite voi olla vielä määritettävänä. Yritä myöhemmin uudelleen.\nJos tämä jatkuu, palvelin ei ehkä ole luotettava.\nJatketaanko silti?"), + ("ID whitelisting", "ID sallintalista"), + ("Use ID whitelisting", "Käytä ID sallintalistaa"), + ("id_whitelist_tip", "Vain sallitut ID:t voivat muodostaa yhteyden"), + ("id_whitelist_wildcard_tip", "Jokerimerkit ovat tuettuja: '*' vastaa mitä tahansa määrää merkkejä, '?' täsmälleen yhtä merkkiä"), + ("Invalid ID", "Virheellinen ID"), + ("Your ID is blocked by the peer", "Vastapuoli on estänyt ID:si"), + ("Your ip is blocked by the peer", "Vastapuoli on estänyt IP-osoitteesi"), + ("id_whitelist_caveat_tip", "ID on yhdistävän asiakkaan ilmoittama. Sallintalista pienentää altistusta eikä korvaa salasanaa tai 2FA:ta"), + ("whitelist_cidr_tip", "CIDR-merkintä on tuettu, esimerkiksi 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 2ddb4e84da7..9a4acf6f336 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Quelqu’un active le mode de confidentialité, désactiver"), ("Unsupported", "Non pris en charge"), ("Peer denied", "Refusé par l’appareil distant"), - ("Please install plugins", "Veuillez installer les plugins"), ("Peer exit", "Désactivé par l’appareil distant"), ("Failed to turn off", "Échec de la désactivation"), ("Turned off", "Désactivé"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empreinte numérique"), ("Copy Fingerprint", "Copier l’empreinte numérique"), ("no fingerprints", "Aucune empreinte numérique"), - ("Select a peer", "Sélectionnez l’appareil distant"), - ("Select peers", "Sélectionnez les appareils distants"), - ("Plugins", "Plugins"), - ("Uninstall", "Désinstaller"), ("Update", "Mettre à jour"), - ("Enable", "Activer"), - ("Disable", "Désactiver"), - ("Options", "Options"), ("resolution_original_tip", "Résolution d’origine"), ("resolution_fit_local_tip", "Adapter à la résolution locale"), ("resolution_custom_tip", "Résolution personnalisée"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Afficher dans la barre d’outils réduite"), ("All monitors", "Tous les moniteurs"), ("#{} monitor", "Moniteur {}"), + ("conn-e2ee-unavailable-tip", "Impossible de vérifier le chiffrement de bout en bout.\nL'appareil distant est peut-être encore en cours de configuration. Réessayez plus tard.\nSi le problème persiste, le serveur n'est peut-être pas fiable.\nContinuer quand même ?"), + ("ID whitelisting", "Liste blanche d’ID"), + ("Use ID whitelisting", "Utiliser une liste blanche d’ID"), + ("id_whitelist_tip", "Seuls les ID inclus dans la liste blanche pourront accéder à mon appareil"), + ("id_whitelist_wildcard_tip", "Les caractères génériques sont pris en charge : '*' correspond à un nombre quelconque de caractères, '?' à un seul caractère"), + ("Invalid ID", "ID non valide"), + ("Your ID is blocked by the peer", "Votre ID est bloqué par l’appareil distant"), + ("Your ip is blocked by the peer", "Votre adresse IP est bloquée par l’appareil distant"), + ("id_whitelist_caveat_tip", "L’ID est déclaré par le client qui se connecte. Cette liste blanche réduit l’exposition et ne remplace ni le mot de passe ni la 2FA"), + ("whitelist_cidr_tip", "La notation CIDR est prise en charge, par exemple 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 52909eb351f..f2d807c4bbd 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "ვიღაცამ ჩართო კონფიდენციალურობის რეჟიმი, გასვლა"), ("Unsupported", "არ არის მხარდაჭერილი"), ("Peer denied", "უარყოფილია დაშორებული კვანძის მიერ"), - ("Please install plugins", "დააინსტალირეთ პლაგინები"), ("Peer exit", "გათიშულია მომხმარებლის მიერ"), ("Failed to turn off", "გამორთვა შეუძლებელია"), ("Turned off", "გამორთული"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ანაბეჭდი"), ("Copy Fingerprint", "ანაბეჭდის კოპირება"), ("no fingerprints", "ანაბეჭდები არ არის"), - ("Select a peer", "აირჩიეთ დისტანციური კვანძი"), - ("Select peers", "აირჩიეთ დისტანციური კვანძები"), - ("Plugins", "დანამატები"), - ("Uninstall", "წაშლა"), ("Update", "განახლება"), - ("Enable", "ჩართვა"), - ("Disable", "გამორთვა"), - ("Options", "პარამეტრები"), ("resolution_original_tip", "საწყისი გარჩევადობა"), ("resolution_fit_local_tip", "ლოკალური გარჩევადობის შესაბამისი"), ("resolution_custom_tip", "მორგებული გარჩევადობა"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "ჩვენება ჩაკეცილ ხელსაწყოთა ზოლზე"), ("All monitors", "ყველა მონიტორი"), ("#{} monitor", "მონიტორი {}"), + ("conn-e2ee-unavailable-tip", "ბოლომდე დაშიფვრის გადამოწმება ვერ მოხერხდა.\nდისტანციური მოწყობილობა შესაძლოა ჯერ კიდევ მზადდება. სცადეთ მოგვიანებით.\nთუ ეს კვლავ გაგრძელდება, სერვერი შესაძლოა არასანდო იყოს.\nმაინც გააგრძელებთ?"), + ("ID whitelisting", "დაშვებული ID-ების სია"), + ("Use ID whitelisting", "ID თეთრი სიის გამოყენება"), + ("id_whitelist_tip", "მხოლოდ თეთრ სიაში არსებულ ID-ებს შეუძლიათ ჩემს მოწყობილობაზე წვდომა."), + ("id_whitelist_wildcard_tip", "მხარდაჭერილია ვაილდქარდები: '*' ემთხვევა ნებისმიერი რაოდენობის სიმბოლოს, '?' — ზუსტად ერთ სიმბოლოს"), + ("Invalid ID", "არასწორი ID"), + ("Your ID is blocked by the peer", "თქვენი ID დაბლოკილია მეორე მხარის მიერ"), + ("Your ip is blocked by the peer", "თქვენი IP მისამართი დაბლოკილია მეორე მხარის მიერ"), + ("id_whitelist_caveat_tip", "ID-ს აცხადებს დამაკავშირებელი კლიენტი. თეთრი სია ამცირებს ექსპოზიციას და ვერ ჩაანაცვლებს პაროლს ან 2FA-ს"), + ("whitelist_cidr_tip", "მხარდაჭერილია CIDR ჩანაწერი, მაგალითად 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 1c1d89cae64..28c4b7a898d 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "કોઈએ પ્રાઇવસી મોડ ચાલુ કર્યો છે, બહાર નીકળો"), ("Unsupported", "અસમર્થિત"), ("Peer denied", "સામેથી નકારવામાં આવ્યું"), - ("Please install plugins", "કૃપા કરીને પ્લગઇન્સ ઇન્સ્ટોલ કરો"), ("Peer exit", "સામેથી કોઈ બહાર નીકળી ગયું"), ("Failed to turn off", "બંધ કરવામાં નિષ્ફળ"), ("Turned off", "બંધ કરવામાં આવ્યું"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ફિંગરપ્રિન્ટ"), ("Copy Fingerprint", "ફિંગરપ્રિન્ટ કોપી કરો"), ("no fingerprints", "કોઈ ફિંગરપ્રિન્ટ નથી"), - ("Select a peer", "એક પીઅર પસંદ કરો"), - ("Select peers", "પીઅર્સ પસંદ કરો"), - ("Plugins", "પ્લગઇન્સ"), - ("Uninstall", "અનઇન્સ્ટોલ કરો"), ("Update", "અપડેટ કરો"), - ("Enable", "સક્ષમ કરો"), - ("Disable", "અક્ષમ કરો"), - ("Options", "વિકલ્પો"), ("resolution_original_tip", "મૂળ રિઝોલ્યુશન"), ("resolution_fit_local_tip", "સ્ક્રીન મુજબ ફીટ કરો"), ("resolution_custom_tip", "કસ્ટમ રિઝોલ્યુશન"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "ન્યૂનતમ કરેલા ટૂલબાર પર બતાવો"), ("All monitors", "બધા મોનિટર"), ("#{} monitor", "મોનિટર {}"), + ("conn-e2ee-unavailable-tip", "એન્ડ-ટુ-એન્ડ એન્ક્રિપ્શન ચકાસી શકાયું નથી.\nરિમોટ ઉપકરણ હજી સેટ થઈ રહ્યું હોઈ શકે છે. પછીથી ફરી પ્રયાસ કરો.\nજો આ ચાલુ રહે, તો સર્વર અવિશ્વસનીય હોઈ શકે છે.\nશું તેમ છતાં ચાલુ રાખવું?"), + ("ID whitelisting", "ID વ્હાઇટલિસ્ટિંગ"), + ("Use ID whitelisting", "ID વ્હાઇટલિસ્ટિંગનો ઉપયોગ કરો"), + ("id_whitelist_tip", "માત્ર વ્હાઇટલિસ્ટ કરેલ ID જ મને એક્સેસ કરી શકે છે"), + ("id_whitelist_wildcard_tip", "વાઇલ્ડકાર્ડ સપોર્ટેડ છે: '*' કોઈપણ સંખ્યાના અક્ષરો સાથે મેળ ખાય છે, '?' બરાબર એક અક્ષર સાથે"), + ("Invalid ID", "અમાન્ય ID"), + ("Your ID is blocked by the peer", "તમારું ID સામેના પક્ષ દ્વારા બ્લોક કરવામાં આવ્યું છે"), + ("Your ip is blocked by the peer", "તમારું IP સામેના પક્ષ દ્વારા બ્લોક કરવામાં આવ્યું છે"), + ("id_whitelist_caveat_tip", "ID કનેક્ટ થતા ક્લાયન્ટ દ્વારા જણાવવામાં આવે છે. વ્હાઇટલિસ્ટ એક્સપોઝર ઘટાડે છે અને પાસવર્ડ કે 2FA નો વિકલ્પ નથી"), + ("whitelist_cidr_tip", "CIDR નોટેશન સપોર્ટેડ છે, ઉदાહરણ તરીકે 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 5317804612a..e11826d0178 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "מישהו הפעיל מצב פרטיות, מתבצעת יציאה"), ("Unsupported", "לא נתמך"), ("Peer denied", "הצד השני סירב"), - ("Please install plugins", "אנא התקן תוספים"), ("Peer exit", "הצד השני התנתק"), ("Failed to turn off", "הכיבוי נכשל"), ("Turned off", "מכובה"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Select a peer", "בחר עמית"), - ("Select peers", "בחר עמיתים"), - ("Plugins", "תוספים"), - ("Uninstall", "הסר"), ("Update", "עדכן"), - ("Enable", "פועל"), - ("Disable", "כבוי"), - ("Options", "אפשרויות"), ("resolution_original_tip", "רזולוציה מקורית"), ("resolution_fit_local_tip", "התאם לרזולוציה מקומית"), ("resolution_custom_tip", "רזולוציה מותאמת אישית"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "הצגה בסרגל הכלים הממוזער"), ("All monitors", "כל המסכים"), ("#{} monitor", "מסך {}"), + ("conn-e2ee-unavailable-tip", "לא ניתן לאמת הצפנה מקצה לקצה.\nייתכן שהמכשיר המרוחק עדיין בתהליך הגדרה. נסה שוב מאוחר יותר.\nאם זה ממשיך לקרות, ייתכן שהשרת אינו מהימן.\nלהמשיך בכל זאת?"), + ("ID whitelisting", "רשימת מזהים מורשים"), + ("Use ID whitelisting", "השתמש ברשימה לבנה של מזהים"), + ("id_whitelist_tip", "רק מזהים מהרשימה הלבנה יכולים לגשת אלי"), + ("id_whitelist_wildcard_tip", "נתמכים תווים כלליים: '*' תואם כל מספר של תווים, '?' תואם תו אחד בדיוק"), + ("Invalid ID", "מזהה לא תקין"), + ("Your ID is blocked by the peer", "המזהה שלך נחסם על ידי הצד המרוחק"), + ("Your ip is blocked by the peer", "כתובת ה-IP שלך נחסמה על ידי הצד המרוחק"), + ("id_whitelist_caveat_tip", "המזהה מדווח על ידי הלקוח המתחבר. הרשימה הלבנה מצמצמת חשיפה ואינה מחליפה סיסמה או 2FA"), + ("whitelist_cidr_tip", "יש תמיכה בסימון CIDR, לדוגמה 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 98b259d9f13..0b1da4efeb2 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "किसी ने गोपनीयता मोड चालू किया है, बाहर निकल रहे हैं"), ("Unsupported", "असमर्थित"), ("Peer denied", "दूसरे सिस्टम ने मना कर दिया"), - ("Please install plugins", "कृपया प्लगइन्स इंस्टॉल करें"), ("Peer exit", "दूसरा सिस्टम बाहर निकल गया"), ("Failed to turn off", "बंद करने में विफल"), ("Turned off", "बंद कर दिया गया"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "फिंगरप्रिंट"), ("Copy Fingerprint", "फिंगरप्रिंट कॉपी करें"), ("no fingerprints", "कोई फिंगरप्रिंट नहीं"), - ("Select a peer", "एक पीयर (Peer) चुनें"), - ("Select peers", "पीयर्स चुनें"), - ("Plugins", "प्लगइन्स"), - ("Uninstall", "अनइंस्टॉल करें"), ("Update", "अपडेट करें"), - ("Enable", "सक्षम करें"), - ("Disable", "अक्षम करें"), - ("Options", "विकल्प"), ("resolution_original_tip", "मूल रिज़ॉल्यूशन"), ("resolution_fit_local_tip", "स्थानीय स्क्रीन में फिट करें"), ("resolution_custom_tip", "कस्टम रिज़ॉल्यूशन"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "न्यूनतम किए गए टूलबार पर दिखाएं"), ("All monitors", "सभी मॉनिटर"), ("#{} monitor", "मॉनिटर {}"), + ("conn-e2ee-unavailable-tip", "एंड-टू-एंड एन्क्रिप्शन सत्यापित नहीं किया जा सका।\nदूरस्थ डिवाइस अभी भी सेट अप हो रहा हो सकता है। बाद में फिर प्रयास करें।\nयदि यह समस्या बनी रहती है, तो सर्वर अविश्वसनीय हो सकता है।\nफिर भी जारी रखें?"), + ("ID whitelisting", "ID श्वेतसूची (Whitelisting)"), + ("Use ID whitelisting", "ID श्वेतसूची का उपयोग करें"), + ("id_whitelist_tip", "केवल श्वेतसूचीबद्ध ID ही मुझ तक पहुंच सकते हैं"), + ("id_whitelist_wildcard_tip", "वाइल्डकार्ड समर्थित हैं: '*' किसी भी संख्या के अक्षरों से मेल खाता है, '?' ठीक एक अक्षर से"), + ("Invalid ID", "अमान्य ID"), + ("Your ID is blocked by the peer", "आपकी ID दूसरे पक्ष द्वारा अवरुद्ध कर दी गई है"), + ("Your ip is blocked by the peer", "आपका IP दूसरे पक्ष द्वारा अवरुद्ध कर दिया गया है"), + ("id_whitelist_caveat_tip", "ID कनेक्ट करने वाले क्लाइंट द्वारा बताई जाती है। श्वेतसूची जोखिम कम करती है और पासवर्ड या 2FA का विकल्प नहीं है"), + ("whitelist_cidr_tip", "CIDR नोटेशन समर्थित है, उदाहरण के लिए 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 59909030a16..d74ab784f1b 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Netko je uključio način privatnosti, izlaz."), ("Unsupported", "Nepodržano"), ("Peer denied", "Klijent zabranjen"), - ("Please install plugins", "Molimo instalirajte dodatke"), ("Peer exit", "Klijent je izašao"), ("Failed to turn off", "Greška kod isključenja"), ("Turned off", "Isključeno"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopirat otisak"), ("no fingerprints", "nema otiska"), - ("Select a peer", "Izbor druge strane"), - ("Select peers", "Odaberite druge strane"), - ("Plugins", "Dodaci"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Dopustiti"), - ("Disable", "Zabraniti"), - ("Options", "Mogućnosti"), ("resolution_original_tip", "Izvorna rezolucija"), ("resolution_fit_local_tip", "Podesite lokalnu rezoluciju"), ("resolution_custom_tip", "Prilagođena rezolucija"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Prikaži na minimiziranoj alatnoj traci"), ("All monitors", "Svi monitori"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "End-to-end enkripcija nije mogla biti potvrđena.\nUdaljeni uređaj se možda još postavlja. Pokušajte ponovno kasnije.\nAko se to nastavi događati, poslužitelj možda nije pouzdan.\nIpak nastaviti?"), + ("ID whitelisting", "ID pouzdana lista"), + ("Use ID whitelisting", "Koristi popis pouzdanih ID-ova"), + ("id_whitelist_tip", "Mogu mi pristupiti samo dozvoljeni ID-ovi"), + ("id_whitelist_wildcard_tip", "Podržani su zamjenski znakovi: '*' odgovara bilo kojem broju znakova, '?' točno jednom znaku"), + ("Invalid ID", "Nevažeći ID"), + ("Your ID is blocked by the peer", "Vaš ID je blokiralo udaljeno računalo"), + ("Your ip is blocked by the peer", "Vašu IP adresu je blokiralo udaljeno računalo"), + ("id_whitelist_caveat_tip", "ID prijavljuje klijent koji se povezuje. Ova lista smanjuje izloženost i ne zamjenjuje lozinku ni 2FA"), + ("whitelist_cidr_tip", "Podržan je CIDR zapis, primjerice 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 31ba1ce05c1..3244269b491 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Valaki bekacsolta az inkognitó módot, lépjen ki"), ("Unsupported", "Nem támogatott"), ("Peer denied", "Elutasítva a távoli fél által"), - ("Please install plugins", "Telepítse a bővítményeket"), ("Peer exit", "A távoli fél kilépett"), ("Failed to turn off", "Nem sikerült kikapcsolni"), ("Turned off", "Kikapcsolva"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Ujjlenyomat"), ("Copy Fingerprint", "Ujjlenyomat másolása"), ("no fingerprints", "nincsenek ujjlenyomatok"), - ("Select a peer", "Egy távoli állomás kiválasztása"), - ("Select peers", "Távoli állomások kiválasztása"), - ("Plugins", "Beépülő modulok"), - ("Uninstall", "Eltávolítás"), ("Update", "Frissítés"), - ("Enable", "Engedélyezés"), - ("Disable", "Letiltás"), - ("Options", "Opciók"), ("resolution_original_tip", "Eredeti felbontás"), ("resolution_fit_local_tip", "Helyi felbontás beállítása"), ("resolution_custom_tip", "Testre szabható felbontás"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Megjelenítés a kis méretű eszköztáron"), ("All monitors", "Minden monitor"), ("#{} monitor", "{}. monitor"), + ("conn-e2ee-unavailable-tip", "A végpontok közötti titkosítás nem volt ellenőrizhető.\nA távoli eszköz talán még beállítás alatt áll. Próbálja újra később.\nHa ez továbbra is előfordul, a szerver lehet, hogy nem megbízható.\nFolytatja így is?"), + ("ID whitelisting", "Azonosító engedélyezési lista"), + ("Use ID whitelisting", "Azonosító engedélyezési lista használata"), + ("id_whitelist_tip", "Csak az engedélyezési listán szereplő azonosítók kapcsolódhatnak"), + ("id_whitelist_wildcard_tip", "Helyettesítő karakterek használhatók: a '*' tetszőleges számú karakternek, a '?' pontosan egy karakternek felel meg"), + ("Invalid ID", "Érvénytelen azonosító"), + ("Your ID is blocked by the peer", "Az azonosítóját a távoli fél letiltotta"), + ("Your ip is blocked by the peer", "Az IP-címét a távoli fél letiltotta"), + ("id_whitelist_caveat_tip", "Az azonosítót a csatlakozó kliens jelenti. Az engedélyezési lista csökkenti a kitettséget, és nem helyettesíti a jelszót vagy a 2FA-t"), + ("whitelist_cidr_tip", "A CIDR jelölés támogatott, például 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index c9d2530e231..594ea50f67f 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Seseorang mengaktifkan mode privasi, keluar"), ("Unsupported", "Tidak didukung"), ("Peer denied", "Rekan menolak"), - ("Please install plugins", "Silakan instal plugin"), ("Peer exit", "Rekan keluar"), ("Failed to turn off", "Gagal mematikan"), ("Turned off", "Dimatikan"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sidik jari"), ("Copy Fingerprint", "Salin sidik jari"), ("no fingerprints", "Tidak ada sidik jari"), - ("Select a peer", "Pilih rekan"), - ("Select peers", "Pilih rekan-rekan"), - ("Plugins", "Plugin"), - ("Uninstall", "Hapus instalasi"), ("Update", "Perbarui"), - ("Enable", "Aktifkan"), - ("Disable", "Nonaktifkan"), - ("Options", "Opsi"), ("resolution_original_tip", "Resolusi original"), ("resolution_fit_local_tip", "Sesuaikan resolusi lokal"), ("resolution_custom_tip", "Resolusi kustom"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Tampilkan di bilah alat yang diperkecil"), ("All monitors", "Semua monitor"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "Tidak dapat memverifikasi enkripsi ujung ke ujung.\nPerangkat jarak jauh mungkin masih disiapkan. Coba lagi nanti.\nJika ini terus terjadi, server mungkin tidak tepercaya.\nTetap lanjutkan?"), + ("ID whitelisting", "Daftar ID yang diizinkan"), + ("Use ID whitelisting", "Gunakan daftar ID yang diizinkan"), + ("id_whitelist_tip", "Hanya ID yang diizinkan dapat mengakses"), + ("id_whitelist_wildcard_tip", "Wildcard didukung: '*' cocok dengan berapa pun jumlah karakter, '?' dengan tepat satu karakter"), + ("Invalid ID", "ID tidak valid"), + ("Your ID is blocked by the peer", "ID Anda diblokir oleh perangkat remote"), + ("Your ip is blocked by the peer", "IP Anda diblokir oleh perangkat remote"), + ("id_whitelist_caveat_tip", "ID dilaporkan oleh klien yang terhubung. Daftar ini mengurangi paparan dan bukan pengganti kata sandi atau 2FA"), + ("whitelist_cidr_tip", "Notasi CIDR didukung, misalnya 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index f5f9c88f505..9747a35c309 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Qualcuno ha attivato la modalità privacy, uscita"), ("Unsupported", "Non supportato"), ("Peer denied", "Accesso negato al dispositivo remoto"), - ("Please install plugins", "Installa i plugin"), ("Peer exit", "Uscita dal dispostivo remoto"), ("Failed to turn off", "Impossibile spegnere"), ("Turned off", "Spegni"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Copia firma digitale"), ("no fingerprints", "Nessuna firma digitale"), - ("Select a peer", "Seleziona dispositivo remoto"), - ("Select peers", "Seleziona dispositivi remoti"), - ("Plugins", "Plugin"), - ("Uninstall", "Disinstalla"), ("Update", "Aggiorna"), - ("Enable", "Abilita"), - ("Disable", "Disabilita"), - ("Options", "Opzioni"), ("resolution_original_tip", "Risoluzione originale"), ("resolution_fit_local_tip", "Adatta risoluzione locale"), ("resolution_custom_tip", "Risoluzione personalizzata"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Visualizza nella barra strumenti ridotta a icona"), ("All monitors", "Tutti gli schermi"), ("#{} monitor", "Schermo {}"), + ("conn-e2ee-unavailable-tip", "Impossibile verificare la crittografia end-to-end.\nIl dispositivo remoto potrebbe essere ancora in configurazione. Riprova più tardi.\nSe il problema persiste, il server potrebbe non essere attendibile.\nVuoi continuare?"), + ("ID whitelisting", "ID autorizzati"), + ("Use ID whitelisting", "Usa elenco ID autorizzati"), + ("id_whitelist_tip", "Possono connettersi a questo desktop solo gli ID autorizzati"), + ("id_whitelist_wildcard_tip", "Sono supportati i caratteri jolly: '*' corrisponde a un numero qualsiasi di caratteri, '?' a un solo carattere"), + ("Invalid ID", "ID non valido"), + ("Your ID is blocked by the peer", "Il tuo ID è bloccato dal dispositivo remoto"), + ("Your ip is blocked by the peer", "Il tuo IP è bloccato dal dispositivo remoto"), + ("id_whitelist_caveat_tip", "L'ID è dichiarato dal client che si connette. Questo elenco riduce l'esposizione e non sostituisce la password o la 2FA"), + ("whitelist_cidr_tip", "È supportata la notazione CIDR, ad esempio 192.168.1.0/24"), + ("Continue", "Continua"), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 187fb2b5167..9ff8d2dc416 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "プライバシーモードがオンになりました。終了します。"), ("Unsupported", "対応していません"), ("Peer denied", "リモートホストに拒否されました"), - ("Please install plugins", "プラグインをインストールしてください"), ("Peer exit", "リモートホストが退出しました"), ("Failed to turn off", "オフにできませんでした"), ("Turned off", "オフになりました"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), - ("Select a peer", "リモートコンピューターを選択"), - ("Select peers", "複数のリモートコンピューターを選択"), - ("Plugins", "プラグイン"), - ("Uninstall", "アンインストール"), ("Update", "更新"), - ("Enable", "有効"), - ("Disable", "無効"), - ("Options", "設定"), ("resolution_original_tip", "オリジナルの解像度"), ("resolution_fit_local_tip", "ローカル解像度に合わせる"), ("resolution_custom_tip", "カスタム解像度"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "最小化したツールバーに表示"), ("All monitors", "すべてのディスプレイ"), ("#{} monitor", "ディスプレイ {}"), + ("conn-e2ee-unavailable-tip", "エンドツーエンド暗号化を確認できませんでした。\nリモートデバイスはまだ準備中の可能性があります。後でもう一度お試しください。\nこの問題が続く場合、サーバーが信頼できない可能性があります。\nそれでも続行しますか?"), + ("ID whitelisting", "ID ホワイトリスト"), + ("Use ID whitelisting", "ID ホワイトリストを使用する"), + ("id_whitelist_tip", "ホワイトリストに登録された ID からのみ接続を許可します"), + ("id_whitelist_wildcard_tip", "ワイルドカードが使用できます。'*' は任意の数の文字、'?' はちょうど1文字に一致します"), + ("Invalid ID", "無効な ID"), + ("Your ID is blocked by the peer", "あなたの ID は接続先によってブロックされています"), + ("Your ip is blocked by the peer", "あなたの IP アドレスは接続先によってブロックされています"), + ("id_whitelist_caveat_tip", "ID は接続するクライアントから申告されます。ホワイトリストは露出を減らすもので、パスワードや 2FA の代わりにはなりません"), + ("whitelist_cidr_tip", "CIDR 表記に対応しています。例: 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 60560cb02e4..a55eb6695d0 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -44,7 +44,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_change_tip", "a-z, A-Z, 0-9, -(대시) 및 _(밑줄) 문자만 허용됩니다. 첫 글자는 a-z, A-Z여야 합니다. 길이는 6에서 16 사이여야 합니다."), ("Website", "웹사이트"), ("About", "정보"), - ("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다!"), + ("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다! - 한국어 번역: 비너스걸"), ("Privacy Statement", "개인정보 보호정책"), ("Mute", "음소거"), ("Build Date", "빌드 날짜"), @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "누군가 개인정보 보호 모드를 켰습니다, 연결을 종료합니다"), ("Unsupported", "지원되지 않음"), ("Peer denied", "연결 거부됨"), - ("Please install plugins", "플러그인을 설치해주세요"), ("Peer exit", "피어 종료"), ("Failed to turn off", "끄기 실패"), ("Turned off", "꺼짐"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Select a peer", "피어 선택"), - ("Select peers", "피어 선택"), - ("Plugins", "플러그인"), - ("Uninstall", "설치 제거"), ("Update", "업데이트"), - ("Enable", "허용"), - ("Disable", "사용 안 함"), - ("Options", "옵션"), ("resolution_original_tip", "원본 해상도"), ("resolution_fit_local_tip", "로컬 화면에 맞춤"), ("resolution_custom_tip", "사용자 지정 해상도"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "최소화된 도구 모음에 표시"), ("All monitors", "모든 모니터"), ("#{} monitor", "#{} 모니터"), + ("conn-e2ee-unavailable-tip", "종단 간 암호화를 확인할 수 없습니다.\n원격 장치가 여전히 설정 중일 수 있습니다. 나중에 다시 시도해 보세요.\n이런 일이 계속 발생하면 서버가 신뢰할 수 없을 수도 있습니다.\n어쨌든 계속하시겠습니까?"), + ("ID whitelisting", "ID 화이트리스트"), + ("Use ID whitelisting", "ID 화이트리스트 사용"), + ("id_whitelist_tip", "화이트리스트에 있는 ID만 나에게 액세스할 수 있음"), + ("id_whitelist_wildcard_tip", "와일드카드를 지원합니다: '*'는 임의 개수의 문자와, '?'는 정확히 한 문자와 일치합니다"), + ("Invalid ID", "유효하지 않은 ID입니다"), + ("Your ID is blocked by the peer", "귀하의 ID가 상대방에 의해 차단되었습니다"), + ("Your ip is blocked by the peer", "귀하의 IP가 상대방에 의해 차단되었습니다"), + ("id_whitelist_caveat_tip", "ID는 연결하는 클라이언트가 보고합니다. 화이트리스트는 노출을 줄이는 것으로 비밀번호나 2FA를 대체하지 않습니다"), + ("whitelist_cidr_tip", "CIDR 표기를 지원합니다. 예: 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 0f1dfa9baba..998b74172ed 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Біреу құпиялылық модасын қосты, шығу"), ("Unsupported", "Қолдаусыз"), ("Peer denied", "Пир қабылдамады"), - ("Please install plugins", "Плагиндерді орнатуды өтінеміз"), ("Peer exit", "Пирдің шығуы"), ("Failed to turn off", "Сөндіру сәтсіз болды"), ("Turned off", "Өшірілген"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Саусақ ізі"), ("Copy Fingerprint", "Саусақ ізін көшіру"), ("no fingerprints", "Саусақ іздері жоқ"), - ("Select a peer", "Пир таңдау"), - ("Select peers", "Пирлерді таңдау"), - ("Plugins", "Плагиндер"), - ("Uninstall", "Жою"), ("Update", "Жаңарту"), - ("Enable", "Қосу"), - ("Disable", "Өшіру"), - ("Options", "Опциялар"), ("resolution_original_tip", "Түпнұсқа ажыратымдылық"), ("resolution_fit_local_tip", "Лақал ажыратымдылыққа сыйғызу"), ("resolution_custom_tip", "Теңшеулі ажыратымдылық"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Кішірейтілген құралдар тақтасында көрсету"), ("All monitors", "Барлық мониторлар"), ("#{} monitor", "Монитор {}"), + ("conn-e2ee-unavailable-tip", "Ұштан-ұшқа шифрлауды тексеру мүмкін болмады.\nҚашықтағы құрылғы әлі бапталып жатқан болуы мүмкін. Кейінірек қайталап көріңіз.\nЕгер бұл қайталана берсе, сервер сенімсіз болуы мүмкін.\nСонда да жалғастыру керек пе?"), + ("ID whitelisting", "ID Ақ-тізімі"), + ("Use ID whitelisting", "ID ақ-тізімін қолдану"), + ("id_whitelist_tip", "Маған тек ақ-тізімделген ID қол жеткізе алады"), + ("id_whitelist_wildcard_tip", "Қойылмалы таңбаларға қолдау көрсетіледі: '*' кез келген таңба санына, '?' дәл бір таңбаға сәйкес келеді"), + ("Invalid ID", "Бұрыс ID"), + ("Your ID is blocked by the peer", "Сіздің ID қарсы тараппен бұғатталған"), + ("Your ip is blocked by the peer", "Сіздің IP-мекенжайыңыз қарсы тараппен бұғатталған"), + ("id_whitelist_caveat_tip", "ID қосылатын клиентпен хабарланады. Ақ-тізім әсер ету аумағын азайтады және құпия сөзді немесе 2FA-ны алмастырмайды"), + ("whitelist_cidr_tip", "CIDR жазбасына қолдау көрсетіледі, мысалы 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 8065ab00f0a..5611cc1920f 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Kažkas įjungė privatumo režimą, išeiti"), ("Unsupported", "Nepalaikomas"), ("Peer denied", "Atšaukė"), - ("Please install plugins", "Įdiekite papildinius"), ("Peer exit", "Nuotolinis mazgas neveikia"), ("Failed to turn off", "Nepavyko išjungti"), ("Turned off", "Išjungti"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Kontrolinis kodas"), ("Copy Fingerprint", "Kopijuoti kontrolinį kodą"), ("no fingerprints", "Nėra kontrolinių kodų"), - ("Select a peer", "Pasirinkite įrenginį"), - ("Select peers", "Pasirinkite įrenginius"), - ("Plugins", "Papildiniai"), - ("Uninstall", "Pašalinti"), ("Update", "Atnaujinti"), - ("Enable", "Įgalinti"), - ("Disable", "Išjungti"), - ("Options", "Parinktys"), ("resolution_original_tip", "Originali skiriamoji geba"), ("resolution_fit_local_tip", "Pritaikyti prie vietinės skiriamosios gebos"), ("resolution_custom_tip", "Tinkinta skiriamoji geba"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Rodyti sumažintoje įrankių juostoje"), ("All monitors", "Visi monitoriai"), ("#{} monitor", "Monitorius {}"), + ("conn-e2ee-unavailable-tip", "Nepavyko patikrinti galinio šifravimo.\nNuotolinis įrenginys galbūt vis dar nustatomas. Bandykite dar kartą vėliau.\nJei tai kartojasi, serveris gali būti nepatikimas.\nVis tiek tęsti?"), + ("ID whitelisting", "ID baltasis sąrašas"), + ("Use ID whitelisting", "Naudoti patikimą ID sąrašą"), + ("id_whitelist_tip", "Mane gali pasiekti tik baltajame sąraše esantys ID"), + ("id_whitelist_wildcard_tip", "Palaikomi pakaitos simboliai: '*' atitinka bet kokį simbolių skaičių, '?' – lygiai vieną simbolį"), + ("Invalid ID", "Netinkamas ID"), + ("Your ID is blocked by the peer", "Jūsų ID užblokavo nuotolinis įrenginys"), + ("Your ip is blocked by the peer", "Jūsų IP adresą užblokavo nuotolinis įrenginys"), + ("id_whitelist_caveat_tip", "ID praneša prisijungiantis klientas. Šis sąrašas sumažina atakos paviršių ir nepakeičia slaptažodžio ar 2FA"), + ("whitelist_cidr_tip", "Palaikomas CIDR žymėjimas, pavyzdžiui 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 23756b2c88c..5ac325cb656 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Kāds ieslēdza privātuma režīmu, iziet"), ("Unsupported", "Neatbalstīts"), ("Peer denied", "Sesija noraidīta"), - ("Please install plugins", "Lūdzu, instalējiet spraudņus"), ("Peer exit", "Iziet no attālās ierīces"), ("Failed to turn off", "Neizdevās izslēgt"), ("Turned off", "Izslēgts"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Pirkstu nospiedums"), ("Copy Fingerprint", "Kopēt pirkstu nospiedumu"), ("no fingerprints", "nav pirkstu nospiedumu"), - ("Select a peer", "Atlasīt līdzīgu"), - ("Select peers", "Atlasīt līdzīgus"), - ("Plugins", "Spraudņi"), - ("Uninstall", "Atinstalēt"), ("Update", "Atjaunināt"), - ("Enable", "Iespējot"), - ("Disable", "Atspējot"), - ("Options", "Opcijas"), ("resolution_original_tip", "Sākotnējā izšķirtspēja"), ("resolution_fit_local_tip", "Atbilst vietējai izšķirtspējai"), ("resolution_custom_tip", "Pielāgota izšķirtspēja"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Rādīt minimizētajā rīkjoslā"), ("All monitors", "Visi monitori"), ("#{} monitor", "Monitors {}"), + ("conn-e2ee-unavailable-tip", "Neizdevās pārbaudīt pilnīgu šifrēšanu.\nAttālā ierīce, iespējams, vēl tiek iestatīta. Mēģiniet vēlreiz vēlāk.\nJa tas turpinās, serveris var nebūt uzticams.\nVai tomēr turpināt?"), + ("ID whitelisting", "ID baltais saraksts"), + ("Use ID whitelisting", "Izmantot balto ID sarakstu"), + ("id_whitelist_tip", "Man var piekļūt tikai baltajā sarakstā iekļautie ID"), + ("id_whitelist_wildcard_tip", "Tiek atbalstītas aizstājzīmes: '*' atbilst jebkuram rakstzīmju skaitam, '?' — tieši vienai rakstzīmei"), + ("Invalid ID", "Nederīgs ID"), + ("Your ID is blocked by the peer", "Jūsu ID ir bloķējusi otra puse"), + ("Your ip is blocked by the peer", "Jūsu IP adresi ir bloķējusi otra puse"), + ("id_whitelist_caveat_tip", "ID paziņo klients, kas veido savienojumu. Baltais saraksts samazina pakļautību un neaizstāj paroli vai 2FA"), + ("whitelist_cidr_tip", "Tiek atbalstīts CIDR pieraksts, piemēram 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index bce3fc9c5b2..c781d288ab8 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "ആരോ പ്രൈവസി മോഡ് ഓൺ ചെയ്തു, പുറത്തുകടക്കുന്നു"), ("Unsupported", "പിന്തുണയ്ക്കുന്നില്ല"), ("Peer denied", "മറുഭാഗത്തുനിന്ന് നിരസിച്ചു"), - ("Please install plugins", "ദയവായി പ്ലഗിനുകൾ ഇൻസ്റ്റാൾ ചെയ്യുക"), ("Peer exit", "മറുഭാഗത്തുനിന്ന് പുറത്തുകടന്നു"), ("Failed to turn off", "ഓഫ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു"), ("Turned off", "ഓഫ് ചെയ്തു"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ഫിംഗർപ്രിന്റ്"), ("Copy Fingerprint", "ഫിംഗർപ്രിന്റ് കോപ്പി ചെയ്യുക"), ("no fingerprints", "ഫിംഗർപ്രിന്റുകൾ ഇല്ല"), - ("Select a peer", "ഒരാളെ തിരഞ്ഞെടുക്കുക"), - ("Select peers", "തിരഞ്ഞെടുക്കുക"), - ("Plugins", "പ്ലഗിനുകൾ"), - ("Uninstall", "അൺഇൻസ്റ്റാൾ ചെയ്യുക"), ("Update", "അപ്ഡേറ്റ് ചെയ്യുക"), - ("Enable", "പ്രവർത്തനക്ഷമമാക്കുക"), - ("Disable", "പ്രവർത്തനരഹിതമാക്കുക"), - ("Options", "ഓപ്ഷനുകൾ"), ("resolution_original_tip", "ഒറിജിനൽ റെസല്യൂഷൻ"), ("resolution_fit_local_tip", "ലോക്കൽ സ്ക്രീനിന് അനുയോജ്യം"), ("resolution_custom_tip", "കസ്റ്റം റെസല്യൂഷൻ"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "ചെറുതാക്കിയ ടൂൾബാറിൽ കാണിക്കുക"), ("All monitors", "എല്ലാ മോണിറ്ററുകളും"), ("#{} monitor", "മോണിറ്റർ {}"), + ("conn-e2ee-unavailable-tip", "എൻഡ്-ടു-എൻഡ് എൻക്രിപ്ഷൻ പരിശോധിക്കാൻ കഴിഞ്ഞില്ല.\nദൂരസ്ഥ ഉപകരണം ഇനിയും സജ്ജീകരണത്തിലായിരിക്കാം. പിന്നീട് വീണ്ടും ശ്രമിക്കുക.\nഇത് തുടർന്നാൽ സർവർ വിശ്വസനീയമല്ലായിരിക്കാം.\nഎങ്കിലും തുടരണമോ?"), + ("ID whitelisting", "ID വൈറ്റ്‌ലിസ്റ്റിംഗ്"), + ("Use ID whitelisting", "ID വൈറ്റ്‌ലിസ്റ്റിംഗ് ഉപയോഗിക്കുക"), + ("id_whitelist_tip", "വൈറ്റ്‌ലിസ്റ്റ് ചെയ്ത ID-കൾക്ക് മാത്രമേ എന്നെ ആക്‌സസ് ചെയ്യാൻ കഴിയൂ"), + ("id_whitelist_wildcard_tip", "വൈൽഡ്കാർഡുകൾ പിന്തുണയ്ക്കുന്നു: '*' എത്ര അക്ഷരങ്ങളുമായും, '?' കൃത്യം ഒരു അക്ഷരവുമായും പൊരുത്തപ്പെടുന്നു"), + ("Invalid ID", "അസാധുവായ ID"), + ("Your ID is blocked by the peer", "നിങ്ങളുടെ ID മറുവശം ബ്ലോക്ക് ചെയ്തിരിക്കുന്നു"), + ("Your ip is blocked by the peer", "നിങ്ങളുടെ IP വിലാസം മറുവശം ബ്ലോക്ക് ചെയ്തിരിക്കുന്നു"), + ("id_whitelist_caveat_tip", "കണക്റ്റ് ചെയ്യുന്ന ക്ലയന്റാണ് ID റിപ്പോർട്ട് ചെയ്യുന്നത്. വൈറ്റ്‌ലിസ്റ്റ് എക്സ്പോഷർ കുറയ്ക്കുന്നു; പാസ്‌വേഡിനോ 2FA-യ്ക്കോ പകരമല്ല"), + ("whitelist_cidr_tip", "CIDR നൊട്ടേഷൻ പിന്തുണയ്ക്കുന്നു, ഉദാഹരണത്തിന് 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 072d1ba2f69..ef26b87d68f 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Noen aktiverte privatlivsmodus, avslutt"), ("Unsupported", "Ikke støttet"), ("Peer denied", "Motpart nektet"), - ("Please install plugins", "Installer plugins"), ("Peer exit", "Motpart-Avslutt"), ("Failed to turn off", "Klarte ikke å skru av"), ("Turned off", "Avslått"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtrykk"), ("Copy Fingerprint", "Kopier fingeravtrykk"), ("no fingerprints", "Ingen fingeravtrykk"), - ("Select a peer", "Velg en motpart"), - ("Select peers", "Velg motparter"), - ("Plugins", "Programtillegg"), - ("Uninstall", "Avinstaller"), ("Update", "Oppdater"), - ("Enable", "Aktiver"), - ("Disable", "Deaktiver"), - ("Options", "Alternativer"), ("resolution_original_tip", "Original oppløsning"), ("resolution_fit_local_tip", "Tilpass til lokal oppløsning"), ("resolution_custom_tip", "Tilpasset oppløsning"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Vis på den minimerte verktøylinjen"), ("All monitors", "Alle skjermer"), ("#{} monitor", "Skjerm {}"), + ("conn-e2ee-unavailable-tip", "Ende-til-ende-kryptering kunne ikke verifiseres.\nDen eksterne enheten kan fortsatt være under oppsett. Prøv igjen senere.\nHvis dette fortsetter, kan serveren være upålitelig.\nFortsette likevel?"), + ("ID whitelisting", "ID-hvitelisting"), + ("Use ID whitelisting", "Bruk ID-hvitelisting"), + ("id_whitelist_tip", "Kun ID-er på hvitelisten kan få adgang til meg"), + ("id_whitelist_wildcard_tip", "Jokertegn støttes: '*' samsvarer med et hvilket som helst antall tegn, '?' med nøyaktig ett tegn"), + ("Invalid ID", "Ugyldig ID"), + ("Your ID is blocked by the peer", "ID-en din er blokkert av motparten"), + ("Your ip is blocked by the peer", "IP-adressen din er blokkert av motparten"), + ("id_whitelist_caveat_tip", "ID-en rapporteres av klienten som kobler til. Hvitelisten reduserer eksponeringen og erstatter ikke passord eller 2FA"), + ("whitelist_cidr_tip", "CIDR-notasjon støttes, for eksempel 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index ff4abd2f2eb..e94d66c94d0 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -2,42 +2,42 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Status"), - ("Your Desktop", "Uw Bureaublad"), + ("Your Desktop", "Uw bureaublad"), ("desk_tip", "Uw bureaublad is toegankelijk met dit ID en wachtwoord."), ("Password", "Wachtwoord"), - ("Ready", "Klaar"), + ("Ready", "Gereed"), ("Established", "Opgezet"), - ("connecting_status", "Verbinding maken met het RustDesk netwerk..."), + ("connecting_status", "Verbinding maken met het RustDesk-netwerk..."), ("Enable service", "Service inschakelen"), - ("Start service", "Start service"), + ("Start service", "Service starten"), ("Service is running", "De service loopt."), ("Service is not running", "De service loopt niet"), ("not_ready_status", "Niet verbonden met de server, controleer de netwerkverbinding"), - ("Control Remote Desktop", "Beheer Extern Bureaublad"), + ("Control Remote Desktop", "Extern Bureaublad beheren"), ("Transfer file", "Bestand overzetten"), ("Connect", "Verbinden"), ("Recent sessions", "Recente sessies"), ("Address book", "Adresboek"), ("Confirmation", "Bevestiging"), ("TCP tunneling", "TCP-tunneling"), - ("Remove", "Verwijder"), - ("Refresh random password", "Vernieuw willekeurig wachtwoord"), - ("Set your own password", "Stel uw eigen wachtwoord in"), + ("Remove", "Verwijderen"), + ("Refresh random password", "Willekeurig wachtwoord vernieuwen"), + ("Set your own password", "Eigen wachtwoord instellen"), ("Enable keyboard/mouse", "Toetsenbord/muis inschakelen"), ("Enable clipboard", "Klembord inschakelen"), ("Enable file transfer", "Bestandsoverdracht inschakelen"), ("Enable TCP tunneling", "TCP-tunneling inschakelen"), - ("IP Whitelisting", "IP Witte Lijst"), - ("ID/Relay Server", "ID-/Relayserver"), - ("Import server config", "Importeer serverconfiguratie"), - ("Export Server Config", "Exporteer serverconfiguratie"), + ("IP Whitelisting", "Witte Lijst met IP's"), + ("ID/Relay Server", "ID-/Relay-server"), + ("Import server config", "Serverconfiguratie importeren"), + ("Export Server Config", "Serverconfiguratie exporteren"), ("Import server configuration successfully", "Importeren serverconfiguratie is geslaagd"), ("Export server configuration successfully", "Exporteren serverconfiguratie is geslaagd"), ("Invalid server configuration", "Ongeldige serverconfiguratie"), ("Clipboard is empty", "Klembord is leeg"), - ("Stop service", "Stop service"), - ("Change ID", "Wijzig ID"), - ("Your new ID", "Uw nieuwe ID"), + ("Stop service", "Service stoppen"), + ("Change ID", "ID wijzigen"), + ("Your new ID", "Nieuwe ID"), ("length %min% to %max%", "lengte %min% tot %max%"), ("starts with a letter", "begint met een letter"), ("allowed characters", "toegestane tekens"), @@ -52,7 +52,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Home", "Startpagina"), ("Audio Input", "Audioingang"), ("Enhancements", "Verbeteringen"), - ("Hardware Codec", "Hardwarecodec"), + ("Hardware Codec", "Hardware-codec"), ("Adaptive bitrate", "Bitrate automatisch aanpassen"), ("ID Server", "ID-server"), ("Relay Server", "Relay-server"), @@ -63,43 +63,43 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("server_not_support", "Nog niet ondersteund door de server"), ("Not available", "Niet beschikbaar"), ("Too frequent", "Te vaak"), - ("Cancel", "Annuleer"), + ("Cancel", "Annuleren"), ("Skip", "Overslaan"), - ("Close", "Sluit"), - ("Retry", "Probeer opnieuw"), + ("Close", "Sluiten"), + ("Retry", "Opnieuw proberen"), ("OK", "OK"), - ("Password Required", "Wachtwoord Vereist"), - ("Please enter your password", "Geef uw wachtwoord in"), + ("Password Required", "Wachtwoord vereist"), + ("Please enter your password", "Voer uw wachtwoord in"), ("Remember password", "Wachtwoord onthouden"), - ("Wrong Password", "Verkeerd Wachtwoord"), - ("Do you want to enter again?", "Wilt u het opnieuw invoeren?"), + ("Wrong Password", "Verkeerd wachtwoord"), + ("Do you want to enter again?", "Opnieuw invoeren?"), ("Connection Error", "Fout bij verbinding"), ("Error", "Fout"), ("Reset by the peer", "Door de peer gereset"), ("Connecting...", "Verbinding maken..."), - ("Connection in progress. Please wait.", "Verbinding wordt gemaakt. Even geduld a.u.b."), - ("Please try 1 minute later", "Probeer 1 minuut later"), + ("Connection in progress. Please wait.", "Verbinding wordt gemaakt. Even geduld."), + ("Please try 1 minute later", "Probeer opnieuw over 1 minuut"), ("Login Error", "Loginfout"), ("Successful", "Geslaagd"), - ("Connected, waiting for image...", "Verbonden, wacht op beeld..."), + ("Connected, waiting for image...", "Verbonden, wachten op beeld..."), ("Name", "Naam"), ("Type", "Type"), ("Modified", "Gewijzigd"), ("Size", "Grootte"), - ("Show Hidden Files", "Toon Verborgen Bestanden"), - ("Receive", "Ontvang"), - ("Send", "Verzend"), - ("Refresh File", "Bestand Verversen"), + ("Show Hidden Files", "Verborgen bestanden weergeven"), + ("Receive", "Ontvangen"), + ("Send", "Verzenden"), + ("Refresh File", "Bestand verversen"), ("Local", "Lokaal"), - ("Remote", "Op Afstand"), - ("Remote Computer", "Externe Computer"), - ("Local Computer", "Lokale Computer"), - ("Confirm Delete", "Bevestig Verwijderen"), - ("Delete", "Verwijder"), + ("Remote", "Op afstand"), + ("Remote Computer", "Externe computer"), + ("Local Computer", "Lokale computer"), + ("Confirm Delete", "Verwijderen bevestigen"), + ("Delete", "Verwijderen"), ("Properties", "Eigenschappen"), - ("Multi Select", "Meervoudig Selecteren"), - ("Select All", "Selecteer Alle"), - ("Unselect All", "De-selecteer Alle"), + ("Multi Select", "Meervoudige selectie"), + ("Select All", "Alles selecteren"), + ("Unselect All", "Selectie opheffen"), ("Empty Directory", "Lege Map"), ("Not an empty directory", "Geen lege map"), ("Are you sure you want to delete this file?", "Weet u zeker dat u dit bestand wilt verwijderen?"), @@ -112,11 +112,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Waiting", "Wachten"), ("Finished", "Voltooid"), ("Speed", "Snelheid"), - ("Custom Image Quality", "Aangepaste Beeldkwaliteit"), - ("Privacy mode", "Privacymodus"), + ("Custom Image Quality", "Aangepaste beeldkwaliteit"), + ("Privacy mode", "Privémodus"), ("Block user input", "Gebruikersinvoer blokkeren"), ("Unblock user input", "Gebruikersinvoer deblokkeren"), - ("Adjust Window", "Venster Aanpassen"), + ("Adjust Window", "Venster aanpassen"), ("Original", "Origineel"), ("Shrink", "Verkleinen"), ("Stretch", "Uitrekken"), @@ -124,55 +124,55 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("ScrollAuto", "Automatisch schuiven"), ("Good image quality", "Goede beeldkwaliteit"), ("Balanced", "Gebalanceerd"), - ("Optimize reaction time", "Optimaliseer reactietijd"), + ("Optimize reaction time", "Responstijd optimaliseren"), ("Custom", "Aangepast"), - ("Show remote cursor", "Toon cursor van extern bureaublad"), + ("Show remote cursor", "Cursor van extern bureaublad weergeven"), ("Show quality monitor", "Kwaliteitsmonitor tonen"), ("Disable clipboard", "Klembord uitschakelen"), ("Lock after session end", "Vergrendelen na einde sessie"), - ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del Invoeren"), + ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del invoeren"), ("Insert Lock", "Vergrendelen"), ("Refresh", "Vernieuwen"), ("ID does not exist", "ID bestaat niet"), ("Failed to connect to rendezvous server", "Verbinding met rendez-vous-server mislukt"), - ("Please try later", "Probeer later opnieuw"), + ("Please try later", "Probeer het later opnieuw"), ("Remote desktop is offline", "Extern bureaublad is offline"), - ("Key mismatch", "Code onjuist"), + ("Key mismatch", "Sleutel komt niet overeen"), ("Timeout", "Time-out"), - ("Failed to connect to relay server", "Verbinden met relayserver mislukt"), - ("Failed to connect via rendezvous server", "Verbinden via rendez-vous-server mislukt"), - ("Failed to connect via relay server", "Verbinden via relaisserver mislukt"), + ("Failed to connect to relay server", "Verbinden met relay-server is mislukt"), + ("Failed to connect via rendezvous server", "Verbinden via rendez-vous-server is mislukt"), + ("Failed to connect via relay server", "Verbinden via relay-server is mislukt"), ("Failed to make direct connection to remote desktop", "Direct verbinden met extern bureaublad is mislukt"), - ("Set Password", "Wachtwoord Instellen"), - ("OS Password", "OS Wachtwoord"), + ("Set Password", "Wachtwoord instellen"), + ("OS Password", "OS-wachtwoord"), ("install_tip", "Door UAC-beperkingen lukt het niet altijd om uw bureaublad op afstand te bedienen. Installeer RustDesk op het systeem om dit probleem te voorkomen."), ("Click to upgrade", "Klik voor upgrade"), ("Configure", "Configureren"), - ("config_acc", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming voor Toegankelijkheid geven."), - ("config_screen", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming voor Schermopname geven."), + ("config_acc", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming geven voor Toegankelijkheid."), + ("config_screen", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming geven voor Schermopname."), ("Installing ...", "Installeren ..."), - ("Install", "Installeer"), + ("Install", "Installeren"), ("Installation", "Installatie"), ("Installation Path", "Locatie"), ("Create start menu shortcuts", "Startmenu-snelkoppelingen maken"), ("Create desktop icon", "Bureaubladpictogram maken"), ("agreement_tip", "Het starten van de installatie betekent het accepteren van de licentieovereenkomst."), ("Accept and Install", "Accepteren en installeren"), - ("End-user license agreement", "Licentieovereenkomst eindgebruiker"), - ("Generating ...", "Genereert ..."), + ("End-user license agreement", "Eindgebruikerslicentieovereenkomst"), + ("Generating ...", "Genereren ..."), ("Your installation is lower version.", "Uw installatie is een lagere versie."), ("not_close_tcp_tip", "Sluit dit venster niet zolang u de tunnel gebruikt"), - ("Listening ...", "Luistert ..."), - ("Remote Host", "Externe Host"), - ("Remote Port", "Externe Poort"), + ("Listening ...", "Luisteren ..."), + ("Remote Host", "Externe host"), + ("Remote Port", "Externe poort"), ("Action", "Actie"), ("Add", "Toevoegen"), - ("Local Port", "Lokale Poort"), - ("Local Address", "Lokaal Adres"), - ("Change Local Port", "Wijzig Lokale Poort"), + ("Local Port", "Lokale poort"), + ("Local Address", "Lokaal adres"), + ("Change Local Port", "Lokale poort wijzigen"), ("setup_server_tip", "Als u een hogere verbindingssnelheid nodig heeft, kunt u ervoor kiezen om uw eigen server aan te maken"), ("Too short, at least 6 characters.", "Te kort, minstens 6 tekens."), - ("The confirmation is not identical.", "De bevestiging is niet identiek."), + ("The confirmation is not identical.", "De bevestiging komt niet overeen."), ("Permissions", "Machtigingen"), ("Accept", "Accepteren"), ("Dismiss", "Afwijzen"), @@ -183,22 +183,22 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relayed and encrypted connection", "Doorgeschakelde en versleutelde verbinding"), ("Direct and unencrypted connection", "Directe en niet-versleutelde verbinding"), ("Relayed and unencrypted connection", "Doorgeschakelde en niet-versleutelde verbinding"), - ("Enter Remote ID", "Voer Extern ID in"), - ("Enter your password", "Voer uw wachtwoord in"), + ("Enter Remote ID", "Extern ID invoeren"), + ("Enter your password", "Wachtwoord invoeren"), ("Logging in...", "Aanmelden..."), ("Enable RDP session sharing", "Delen van RDP-sessie inschakelen"), - ("Auto Login", "Automatisch Aanmelden"), + ("Auto Login", "Automatisch aanmelden"), ("Enable direct IP access", "Directe IP-toegang inschakelen"), ("Rename", "Naam wijzigen"), ("Space", "Spatie"), ("Create desktop shortcut", "Snelkoppeling op bureaublad maken"), - ("Change Path", "Pad Wijzigen"), - ("Create Folder", "Map Maken"), + ("Change Path", "Pad wijzigen"), + ("Create Folder", "Map aanmaken"), ("Please enter the folder name", "Geef de mapnaam op"), - ("Fix it", "Repareer"), + ("Fix it", "Repareren"), ("Warning", "Waarschuwing"), ("Login screen using Wayland is not supported", "Aanmeldingsscherm via Wayland wordt niet ondersteund"), - ("Reboot required", "Opnieuw opstarten vereist"), + ("Reboot required", "Opnieuw opstarten is vereist"), ("Unsupported display server", "Niet-ondersteunde weergaveserver"), ("x11 expected", "x11 verwacht"), ("Port", "Poort"), @@ -210,41 +210,41 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Run without install", "Uitvoeren zonder installatie"), ("Connect via relay", "Verbinden via relay"), ("Always connect via relay", "Altijd verbinden via relay"), - ("whitelist_tip", "Alleen IP-adressen op de witte lijst krijgen toegang tot mijn toestel"), - ("Login", "Log In"), - ("Verify", "Controleer"), - ("Remember me", "Herinner mij"), - ("Trust this device", "Vertrouw dit apparaat"), + ("whitelist_tip", "Alleen IP-adressen op de witte lijst krijgen toegang tot mijn apparaat"), + ("Login", "Inloggen"), + ("Verify", "Controleren"), + ("Remember me", "Mij onthouden"), + ("Trust this device", "Dit apparaat vertrouwen"), ("Verification code", "Verificatiecode"), ("verification_tip", "Er is een verificatiecode naar het geregistreerde e-mailadres gestuurd, voer de verificatiecode in om de verbinding voort te zetten."), - ("Logout", "Log Uit"), + ("Logout", "Uitloggen"), ("Tags", "Labels"), - ("Search ID", "Zoek ID"), + ("Search ID", "ID zoeken"), ("whitelist_sep", "Gescheiden door komma, puntkomma, spatie of nieuwe regel"), - ("Add ID", "ID Toevoegen"), - ("Add Tag", "Label Toevoegen"), + ("Add ID", "ID toevoegen"), + ("Add Tag", "Label toevoegen"), ("Unselect all tags", "Alle labels verwijderen"), ("Network error", "Netwerkfout"), - ("Username missed", "Gebruikersnaam gemist"), - ("Password missed", "Wachtwoord vergeten"), + ("Username missed", "Gebruikersnaam ontbreekt"), + ("Password missed", "Wachtwoord ontbreekt"), ("Wrong credentials", "Verkeerde inloggegevens"), ("The verification code is incorrect or has expired", "De verificatiecode is onjuist of verlopen"), - ("Edit Tag", "Label Bewerken"), + ("Edit Tag", "Label bewerken"), ("Forget Password", "Wachtwoord vergeten"), ("Favorites", "Favorieten"), ("Add to Favorites", "Toevoegen aan Favorieten"), ("Remove from Favorites", "Verwijderen uit Favorieten"), ("Empty", "Leeg"), ("Invalid folder name", "Ongeldige mapnaam"), - ("Socks5 Proxy", "SOCKS5 Proxy"), - ("Socks5/Http(s) Proxy", "SOCKS5/HTTP(S) Proxy"), + ("Socks5 Proxy", "SOCKS5 proxy"), + ("Socks5/Http(s) Proxy", "SOCKS5/HTTP(S) proxy"), ("Discovered", "Ontdekt"), ("install_daemon_tip", "Om te starten bij het opstarten van de computer, moet u de systeemservice installeren."), ("Remote ID", "Extern ID"), ("Paste", "Plakken"), ("Paste here?", "Hier plakken?"), ("Are you sure to close the connection?", "Weet u zeker dat u de verbinding wilt sluiten?"), - ("Download new version", "Download nieuwe versie"), + ("Download new version", "Nieuwe versie downloaden"), ("Touch mode", "Aanraakmodus"), ("Mouse mode", "Muismodus"), ("One-Finger Tap", "Een-Vinger Tik"), @@ -258,21 +258,21 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Three-Finger vertically", "Drie-Vinger verticaal"), ("Mouse Wheel", "Muiswiel"), ("Two-Finger Move", "Twee-Vingers Verplaatsen"), - ("Canvas Move", "Canvas Verplaatsen"), - ("Pinch to Zoom", "Knijp om te Zoomen"), - ("Canvas Zoom", "Canvas Zoom"), - ("Reset canvas", "Reset canvas"), + ("Canvas Move", "Canvas verplaatsen"), + ("Pinch to Zoom", "Knijp om te zoomen"), + ("Canvas Zoom", "Canvas zoom"), + ("Reset canvas", "Canvas herstellen"), ("No permission of file transfer", "Geen toestemming voor bestandsoverdracht"), ("Note", "Opmerking"), ("Connection", "Verbinding"), - ("Share screen", "Scherm Delen"), + ("Share screen", "Scherm delen"), ("Chat", "Chat"), ("Total", "Totaal"), ("items", "items"), ("Selected", "Geselecteerd"), ("Screen Capture", "Schermopname"), ("Input Control", "Invoercontrole"), - ("Audio Capture", "Audio Opnemen"), + ("Audio Capture", "Audio opnemen"), ("Do you accept?", "Geeft u toestemming?"), ("Open System Setting", "Systeeminstelling Openen"), ("How to get Android input permission?", "Hoe krijg ik Android invoer toestemming?"), @@ -292,126 +292,125 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Failed", "Mislukt"), ("Succeeded", "Geslaagd"), ("Someone turns on privacy mode, exit", "Iemand schakelt privacymodus in, afsluiten"), - ("Unsupported", "Niet Ondersteund"), + ("Unsupported", "Niet ondersteund"), ("Peer denied", "Peer geweigerd"), - ("Please install plugins", "Installeer plugins"), ("Peer exit", "Peer afgesloten"), ("Failed to turn off", "Uitschakelen mislukt"), ("Turned off", "Uitgeschakeld"), ("Language", "Taal"), ("Keep RustDesk background service", "RustDesk achtergronddienst behouden"), - ("Ignore Battery Optimizations", "Negeer Batterij-optimalisaties"), + ("Ignore Battery Optimizations", "Batterij-optimalisaties negeren"), ("android_open_battery_optimizations_tip", "Ga naar de volgende pagina met instellingen"), ("Start on boot", "Starten bij Opstarten"), ("Start the screen sharing service on boot, requires special permissions", "Start de schermdelingsservice bij het opstarten, vereist speciale rechten"), ("Connection not allowed", "Verbinding niet toegestaan"), - ("Legacy mode", "Legacymodus"), + ("Legacy mode", "Verouderde modus"), ("Map mode", "Mapmodus"), ("Translate mode", "Vertaalmodus"), - ("Use permanent password", "Gebruik permanent wachtwoord"), - ("Use both passwords", "Gebruik beide wachtwoorden"), - ("Set permanent password", "Stel permanent wachtwoord in"), + ("Use permanent password", "Permanent wachtwoord gebruiken"), + ("Use both passwords", "Beide wachtwoorden gebruiken"), + ("Set permanent password", "Permanent wachtwoord instellen"), ("Enable remote restart", "Herstart op afstand inschakelen"), ("Restart remote device", "Apparaat op afstand herstarten"), ("Are you sure you want to restart", "Weet u zeker dat u wilt herstarten"), ("Restarting remote device", "Apparaat op afstand herstarten"), ("remote_restarting_tip", "Apparaat op afstand wordt opnieuw opgestart, sluit dit bericht en maak na een ogenblik opnieuw verbinding met het permanente wachtwoord."), ("Copied", "Gekopieerd"), - ("Exit Fullscreen", "Volledig Scherm sluiten"), - ("Fullscreen", "Volledig Scherm"), - ("Mobile Actions", "Mobiele Acties"), - ("Select Monitor", "Selecteer Monitor"), + ("Exit Fullscreen", "Volledig scherm sluiten"), + ("Fullscreen", "Volledig scherm"), + ("Mobile Actions", "Mobiele acties"), + ("Select Monitor", "Monitor selecteren"), ("Control Actions", "Controleacties"), ("Display Settings", "Beeldscherminstellingen"), ("Ratio", "Verhouding"), ("Image Quality", "Beeldkwaliteit"), ("Scroll Style", "Scroll Stijl"), - ("Show Toolbar", "Werkbalk Weergeven"), - ("Hide Toolbar", "Verberg Werkbalk"), - ("Direct Connection", "Directe Verbinding"), - ("Relay Connection", "Relaisverbinding"), - ("Secure Connection", "Beveiligde Verbinding"), - ("Insecure Connection", "Onveilige Verbinding"), + ("Show Toolbar", "Werkbalk weergeven"), + ("Hide Toolbar", "Werkbalk verbergen"), + ("Direct Connection", "Directe verbinding"), + ("Relay Connection", "Relay-verbinding"), + ("Secure Connection", "Beveiligde verbinding"), + ("Insecure Connection", "Onveilige verbinding"), ("Scale original", "Oorspronkelijk formaat"), ("Scale adaptive", "Automatisch schalen"), ("General", "Algemeen"), ("Security", "Beveiliging"), ("Theme", "Thema"), - ("Dark Theme", "Donker Thema"), - ("Light Theme", "Licht Thema"), + ("Dark Theme", "Donker thema"), + ("Light Theme", "Licht thema"), ("Dark", "Donker"), ("Light", "Licht"), - ("Follow System", "Volg systeem"), + ("Follow System", "Systeem volgen"), ("Enable hardware codec", "Hardwarecodec inschakelen"), ("Unlock Security Settings", "Beveiligingsinstellingen vrijgeven"), ("Enable audio", "Audio inschakelen"), - ("Unlock Network Settings", "Netwerkinstellingen Vrijgeven"), + ("Unlock Network Settings", "Netwerkinstellingen vrijgeven"), ("Server", "Server"), - ("Direct IP Access", "Directe IP toegang"), + ("Direct IP Access", "Directe IP-toegang"), ("Proxy", "Proxy"), ("Apply", "Toepassen"), ("Disconnect all devices?", "Alle apparaten uitschakelen?"), - ("Clear", "Wis"), + ("Clear", "Wissen"), ("Audio Input Device", "Audio-invoerapparaat"), - ("Use IP Whitelisting", "Gebruik een witte lijst van IP-adressen"), + ("Use IP Whitelisting", "Witte lijst met IP-adressen gebruiken"), ("Network", "Netwerk"), - ("Pin Toolbar", "Werkbalk Vastzetten"), - ("Unpin Toolbar", "Werkbalk Losmaken"), + ("Pin Toolbar", "Werkbalk vastzetten"), + ("Unpin Toolbar", "Werkbalk losmaken"), ("Recording", "Opnemen"), ("Directory", "Map"), ("Automatically record incoming sessions", "Inkomende sessies automatisch opnemen"), ("Automatically record outgoing sessions", "Uitgaande sessies automatisch opnemen"), ("Change", "Aanpassen"), - ("Start session recording", "Start de sessieopname"), - ("Stop session recording", "Stop de sessieopname"), + ("Start session recording", "Sessieopname starten"), + ("Stop session recording", "Sessieopname stoppen"), ("Enable recording session", "Sessieopname activeren"), ("Enable LAN discovery", "LAN-detectie inschakelen"), ("Deny LAN discovery", "LAN-detectie weigeren"), ("Write a message", "Schrijf een bericht"), ("Prompt", "Melding"), - ("Please wait for confirmation of UAC...", "Wacht op bevestiging van UAC..."), + ("Please wait for confirmation of UAC...", "Wacht op UAC-bevestiging..."), ("elevated_foreground_window_tip", "Het momenteel geopende venster van de op afstand bediende computer vereist hogere rechten. Daarom is het momenteel niet mogelijk de muis en het toetsenbord te gebruiken. Vraag de gebruiker wiens computer u op afstand bedient om het venster te minimaliseren of de rechten te verhogen. Om dit probleem in de toekomst te voorkomen, wordt aanbevolen de software te installeren op de op afstand bediende computer."), ("Disconnected", "Afgesloten"), ("Other", "Andere"), ("Confirm before closing multiple tabs", "Bevestig voordat u meerdere tabbladen sluit"), ("Keyboard Settings", "Toetsenbordinstellingen"), - ("Full Access", "Volledige Toegang"), - ("Screen Share", "Scherm Delen"), - ("ubuntu-21-04-required", "Wayland vereist Ubuntu 21.04 of hoger."), - ("wayland-requires-higher-linux-version", "Wayland vereist een hogere versie van Linux distro. Probeer X11 desktop of verander van OS."), - ("xdp-portal-unavailable", "Wayland-schermopname is mislukt. De XDG Desktop Portal is mogelijk gecrasht of niet beschikbaar. Probeer deze opnieuw te starten met `systemctl --user restart xdg-desktop-portal`."), + ("Full Access", "Volledige toegang"), + ("Screen Share", "Scherm delen"), + ("ubuntu-21-04-required", "Wayland vereist Ubuntu versie 21.04 of nieuwer."), + ("wayland-requires-higher-linux-version", "Wayland vereist een linux distro met een hogere versie. Probeer X11 desktop of pas het OS aan."), + ("xdp-portal-unavailable", "Wayland schermopname is mislukt. De XDG Desktop Portal kan gecrashed zijn of is onbeschikbaar. Probeer het opnieuw te starten met `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Selecteer het scherm dat moet worden gedeeld (Bediening aan de kant van de peer)."), ("Show RustDesk", "Toon RustDesk"), ("This PC", "Deze PC"), ("or", "of"), - ("Elevate", "Verhoog"), + ("Elevate", "Verhogen"), ("Zoom cursor", "Zoom cursor"), ("Accept sessions via password", "Sessies accepteren via wachtwoord"), ("Accept sessions via click", "Sessies accepteren via klik"), ("Accept sessions via both", "Accepteer sessies via klik of wachtwoord"), ("Please wait for the remote side to accept your session request...", "Wacht tot de andere kant uw sessieverzoek accepteert..."), - ("One-time Password", "Eenmalig Wachtwoord"), - ("Use one-time password", "Gebruik een eenmalig wachtwoord"), + ("One-time Password", "Eenmalig wachtwoord"), + ("Use one-time password", "Eenmalig wachtwoord gebruiken"), ("One-time password length", "Lengte eenmalig wachtwoord"), ("Request access to your device", "Toegang tot uw toestel aanvragen"), - ("Hide connection management window", "Verberg het venster voor verbindingsbeheer"), + ("Hide connection management window", "Venster voor verbindingsbeheer verbergen"), ("hide_cm_tip", "Dit kan alleen als de toegang via een permanent wachtwoord verloopt."), ("wayland_experiment_tip", "Wayland ondersteuning is slechts experimenteel. Gebruik alstublieft X11 als u onbeheerde toegang nodig heeft."), ("Right click to select tabs", "Rechts klikken om tabbladen te selecteren"), ("Skipped", "Overgeslagen"), ("Add to address book", "Toevoegen aan Adresboek"), ("Group", "Groep"), - ("Search", "Zoek"), + ("Search", "Zoeken"), ("Closed manually by web console", "Handmatig gesloten door webconsole"), ("Local keyboard type", "Lokaal toetsenbord"), - ("Select local keyboard type", "Selecteer lokaal toetsenbord"), + ("Select local keyboard type", "Lokaal toetsenbord selecteren"), ("software_render_tip", "Als u een NVIDIA grafische kaart hebt en het externe venster sluit onmiddellijk na verbinding, kan het helpen om het nieuwe stuurprogramma te installeren en te kiezen voor software rendering. Een software herstart is vereist."), ("Always use software rendering", "Gebruik altijd software rendering"), ("config_input", "Om een extern apparaat met uw toetsenbord te kunnen bedienen, moet u RustDesk toestemming voor Invoer Vastleggen geven."), ("config_microphone", "Om te kunnen chatten moet u RustDesk toestemming voor Microfoon geven."), ("request_elevation_tip", "U kunt ook meer rechten vragen als iemand aan de andere kant aanwezig is."), - ("Wait", "Wacht"), + ("Wait", "Wachten"), ("Elevation Error", "Verhogingsfout"), ("Ask the remote user for authentication", "Vraag de gebruiker op afstand om bevestiging"), ("Choose this if the remote account is administrator", "Kies dit als het externe account de beheerder is"), @@ -428,7 +427,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Weak", "Zwak"), ("Medium", "Middelmatig"), ("Strong", "Sterk"), - ("Switch Sides", "Wissel van kant"), + ("Switch Sides", "Van kant wisselen"), ("Please confirm if you want to share your desktop?", "Bevestig dat u uw bureaublad wilt delen?"), ("Display", "Weergave"), ("Default View Style", "Standaard Weergavestijl"), @@ -441,33 +440,33 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Other Default Options", "Overige Standaardinstellingen"), ("Voice call", "Spraakoproep"), ("Text chat", "Tekstchat"), - ("Stop voice call", "Stop spraakoproep"), - ("relay_hint_tip", "Indien een directe verbinding niet mogelijk is, kunt u proberen verbinding te maken via een Relay Server.\nAls u bij de eerste poging een relaisverbinding tot stand wilt brengen, kunt u het achtervoegsel \"/r\" toevoegen aan het ID of de optie \"Altijd verbinden via relaisserver\" selecteren op de externe terminal."), + ("Stop voice call", "Spraakoproep stoppen"), + ("relay_hint_tip", "Indien een directe verbinding niet mogelijk is, kunt u proberen verbinding te maken via een Relay-server.\nAls u bij de eerste poging een relaisverbinding tot stand wilt brengen, kunt u het achtervoegsel \"/r\" toevoegen aan het ID of de optie \"Altijd verbinden via relaisserver\" selecteren op de externe terminal."), ("Reconnect", "Opnieuw verbinden"), ("Codec", "Codec"), ("Resolution", "Resolutie"), ("No transfers in progress", "Geen overdrachten in uitvoering"), ("Set one-time password length", "Stel de lengte van het eenmalige wachtwoord in"), - ("RDP Settings", "RDP Instellingen"), + ("RDP Settings", "RDP-instellingen"), ("Sort by", "Sorteren op"), - ("New Connection", "Nieuwe Verbinding"), - ("Restore", "Herstel"), + ("New Connection", "Nieuwe verbinding"), + ("Restore", "Herstellen"), ("Minimize", "Minimaliseren"), ("Maximize", "Maximaliseren"), - ("Your Device", "Uw Apparaat"), + ("Your Device", "Uw apparaat"), ("empty_recent_tip", "Oeps, geen recente sessies!\nTijd om een nieuwe te plannen."), ("empty_favorite_tip", "Nog geen favoriete stations op afstand? Laat ons iemand vinden om mee te verbinden en voeg hem toe aan uw favorieten!"), ("empty_lan_tip", "Oh nee, het lijkt erop dat we nog geen extern station hebben ontdekt."), ("empty_address_book_tip", "Oh jee, het lijkt erop dat er momenteel geen externe stations in uw adresboek staan."), - ("Empty Username", "Gebruikersnaam Leeg"), - ("Empty Password", "Wachtwoord Leeg"), + ("Empty Username", "Gebruikersnaam leeg"), + ("Empty Password", "Wachtwoord leeg"), ("Me", "Ik"), ("identical_file_tip", "Dit bestand is identiek aan het bestand van het externe station."), ("show_monitors_tip", "Monitoren weergeven in de werkbalk"), ("View Mode", "Toeschouwermodus"), ("login_linux_tip", "Toegang tot het externe Linux-account"), ("verify_rustdesk_password_tip", "Bevestiging wachtwoord RustDesk"), - ("remember_account_tip", "Herinner dit account"), + ("remember_account_tip", "Onthoud dit account"), ("os_account_desk_tip", "Dit account wordt gebruikt om toegang te krijgen tot het externe besturingssysteem en de bureaubladsessie in onbeheerde modus te activeren."), ("OS Account", "Besturingssysteem account"), ("another_user_login_title_tip", "Een andere gebruiker is al ingelogd."), @@ -481,31 +480,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Default", "Standaard"), ("New RDP", "Nieuwe RDP"), ("Fingerprint", "Vingerafdruk"), - ("Copy Fingerprint", "Kopieer Vingerafdruk"), + ("Copy Fingerprint", "Vingerafdruk kopiëren"), ("no fingerprints", "geen vingerafdrukken"), - ("Select a peer", "Selecteer een peer"), - ("Select peers", "Selecteer peers"), - ("Plugins", "Plugins"), - ("Uninstall", "Verwijder"), ("Update", "Bijwerken"), - ("Enable", "Activeer"), - ("Disable", "Deactiveer"), - ("Options", "Opties"), ("resolution_original_tip", "Oorspronkelijke resolutie"), ("resolution_fit_local_tip", "Lokale resolutie aanpassen"), ("resolution_custom_tip", "Aangepaste resolutie"), ("Collapse toolbar", "Werkbalk samenvouwen"), - ("Accept and Elevate", "Accepteren en Verheffen"), + ("Accept and Elevate", "Accepteren en verhogen"), ("accept_and_elevate_btn_tooltip", "Accepteer de verbinding en verhoog de UAC-machtigingen."), - ("clipboard_wait_response_timeout_tip", "Time-out in afwachting van kopieer-antwoord."), + ("clipboard_wait_response_timeout_tip", "Time-out in afwachting van kopie-antwoord."), ("Incoming connection", "Inkomende verbinding"), ("Outgoing connection", "Uitgaande verbinding"), ("Exit", "Afsluiten"), - ("Open", "Open"), + ("Open", "Openen"), ("logout_tip", "Weet u zeker dat u zich wilt afmelden?"), ("Service", "Achtergrondservice"), - ("Start", "Start"), - ("Stop", "Stop"), + ("Start", "Starten"), + ("Stop", "Stoppen"), ("exceed_max_devices", "Het maximum aantal gecontroleerde apparaten is bereikt."), ("Sync with recent sessions", "Recente sessies synchroniseren"), ("Sort tags", "Labels sorteren"), @@ -513,24 +505,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Move tab to new window", "Tabblad verplaatsen naar nieuw venster"), ("Can not be empty", "Mag niet leeg zijn"), ("Already exists", "Bestaat al"), - ("Change Password", "Wijzig Wachtwoord"), - ("Refresh Password", "Wachtwoord Vernieuwen"), + ("Change Password", "Wachtwoord wijzigen"), + ("Refresh Password", "Wachtwoord vernieuwen"), ("ID", "ID"), ("Grid View", "Rasterweergave"), ("List View", "Lijstweergave"), - ("Select", "Selecteer"), - ("Toggle Tags", "Schakel Tags"), + ("Select", "Selecteren"), + ("Toggle Tags", "Labels wisselen"), ("pull_ab_failed_tip", "Adresboek kan niet worden bijgewerkt"), - ("push_ab_failed_tip", "Synchronisatie van adresboek mislukt"), + ("push_ab_failed_tip", "Synchronisatie van adresboek is mislukt"), ("synced_peer_readded_tip", "Apparaten die aanwezig waren in recente sessies worden gesynchroniseerd met het adresboek."), - ("Change Color", "Kleur Aanpassen"), + ("Change Color", "Kleur aanpassen"), ("Primary Color", "Hoofdkleur"), - ("HSV Color", "HSV Kleur"), + ("HSV Color", "HSV-kleur"), ("Installation Successful!", "Installatie geslaagd!"), ("Installation failed!", "Installatie mislukt!"), ("Reverse mouse wheel", "Muiswiel omkeren"), ("{} sessions", "{} sessies"), - ("scam_title", "U wordt misschien opgelicht!"), + ("scam_title", "U wordt mogelijk opgelicht!"), ("scam_text1", "Als u aan de telefoon bent met iemand die u NIET kent EN VERTROUWT en die u heeft gevraagd om RustDesk te gebruiken en de service te starten, ga dan niet verder en hang onmiddellijk op."), ("scam_text2", "Het is waarschijnlijk een oplichter die probeert uw geld of andere privégegevens te stelen."), ("Don't show again", "Niet opnieuw tonen"), @@ -538,13 +530,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Decline", "Afwijzen"), ("Timeout in minutes", "Time-out in minuten"), ("auto_disconnect_option_tip", "Inkomende sessies automatisch sluiten bij inactiviteit van de gebruiker"), - ("Connection failed due to inactivity", "Automatisch verbinding verbroken wegens inactiviteit"), - ("Check for software update on startup", "Controleer op updates bij opstarten"), + ("Connection failed due to inactivity", "Verbinding automatisch verbroken wegens inactiviteit"), + ("Check for software update on startup", "Controleren op updates bij opstarten"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Upgrade RustDesk Server Pro naar versie {} of nieuwer!"), ("pull_group_failed_tip", "Vernieuwen van groep mislukt"), - ("Filter by intersection", "Filter op kruising"), + ("Filter by intersection", "Filteren op kruising"), ("Remove wallpaper during incoming sessions", "Achtergrond verwijderen tijdens inkomende sessies"), - ("Test", "Test"), + ("Test", "Testen"), ("display_is_plugged_out_msg", "Beeldscherm is uitgeschakeld, schakel over naar het primaire beeldscherm."), ("No displays", "Geen beeldschermen"), ("Open in new window", "Open in een nieuw venster"), @@ -556,19 +548,19 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Small tiles", "Kleine tegels"), ("List", "Overzicht"), ("Virtual display", "Virtuele weergave"), - ("Plug out all", "Sluit alle"), + ("Plug out all", "Alle loskoppelen"), ("True color (4:4:4)", "Ware kleur (4:4:4)"), - ("Enable blocking user input", "Blokkeren van gebruikersinvoer inschakelen"), + ("Enable blocking user input", "Blokkering van gebruikersinvoer inschakelen"), ("id_input_tip", "U kunt een ID, een direct IP of een domein met poort (:) invoeren. Als u toegang wilt tot een apparaat op een andere server, voeg dan een serveradres en public key toe (@?key=), bijvoorbeeld \n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.Als je toegang wilt als apparaat op een openbare server, voer dan \"@public\" in, voor de openbare server is de sleutel niet nodig."), ("privacy_mode_impl_mag_tip", "Modus 1: Overlayscherm"), ("privacy_mode_impl_virtual_display_tip", "Modus 2: Monitor slaapstand"), - ("Enter privacy mode", "Privacymodus openen"), - ("Exit privacy mode", "Privacymodus afsluiten"), + ("Enter privacy mode", "Privémodus openen"), + ("Exit privacy mode", "Privémodus afsluiten"), ("idd_not_support_under_win10_2004_tip", "Het indirecte displaystuurprogramma wordt niet ondersteund. Windows 10 versie 2004 of later is vereist."), ("input_source_1_tip", "Invoerbron 1: Standaard"), ("input_source_2_tip", "Invoerbron 2: Verouderd"), - ("Swap control-command key", "Wissel controle-commando toets"), - ("swap-left-right-mouse", "Wissel linker- en rechtermuisknop"), + ("Swap control-command key", "Control-Command-toets wisselen"), + ("swap-left-right-mouse", "Linker- en rechtermuisknop wisselen"), ("2FA code", "2FA-code"), ("More", "Meer"), ("enable-2fa-title", "Tweefactorauthenticatie inschakelen"), @@ -590,16 +582,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Exist in", "Bestaat in"), ("Read-only", "Alleen-lezen"), ("Read/Write", "Lezen/Schrijven"), - ("Full Control", "Volledige Controle"), + ("Full Control", "Volledige controle"), ("share_warning_tip", "De bovenstaande velden worden gedeeld en zijn zichtbaar voor anderen."), ("Everyone", "Iedereen"), ("ab_web_console_tip", "Meer over de webconsole"), ("allow-only-conn-window-open-tip", "Alleen verbindingen toestaan als het RustDesk-venster geopend is"), ("no_need_privacy_mode_no_physical_displays_tip", "Geen fysieke schermen, geen privémodus nodig."), - ("Follow remote cursor", "Volg de cursor op afstand"), - ("Follow remote window focus", "Volg de focus van het venster op afstand"), - ("default_proxy_tip", "Standaard protocol en poort: Socks5 en 1080"), - ("no_audio_input_device_tip", "Er is geen invoerapparaat gevonden."), + ("Follow remote cursor", "Cursor op afstand volgen"), + ("Follow remote window focus", "Focus van het venster op afstand volgen"), + ("default_proxy_tip", "Standaardprotocol en -poort: Socks5 en 1080"), + ("no_audio_input_device_tip", "Geen invoerapparaat gevonden."), ("Incoming", "Inkomend"), ("Outgoing", "Uitgaand"), ("Clear Wayland screen selection", "Wayland-scherm wissen"), @@ -620,13 +612,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Volume up", "Volume verhogen"), ("Volume down", "Volume verlagen"), ("Power", "Stroom"), - ("Telegram bot", "Telegram bot"), + ("Telegram bot", "Telegram-bot"), ("enable-bot-tip", "Als u deze functie inschakelt, kunt u een 2FA-code ontvangen van uw bot. Het kan ook fungeren als een verbindingsmelding."), ("enable-bot-desc", "1, Open een chat met @BotFather.\n2, Verzend het commando \"/newbot\". Als deze stap voltooid is, ontvangt u een token.\n3, Start een chat met de nieuw aangemaakte bot. Om hem te activeren stuurt u een bericht dat begint met een schuine streep (\"/\"), bijvoorbeeld \"/hello\".\n"), ("cancel-2fa-confirm-tip", "Weet u zeker dat u 2FA wilt annuleren?"), ("cancel-bot-confirm-tip", "Weet u zeker dat u de Telegram-bot wilt annuleren?"), ("About RustDesk", "Over RustDesk"), - ("Send clipboard keystrokes", "Klembord toetsaanslagen verzenden"), + ("Send clipboard keystrokes", "Klembord-toetsaanslagen verzenden"), ("network_error_tip", "Controleer de netwerkverbinding en selecteer 'Opnieuw proberen'."), ("Unlock with PIN", "Ontgrendelen met PIN"), ("Requires at least {} characters", "Vereist minstens {} tekens"), @@ -643,56 +635,56 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("one-way-file-transfer-tip", "Eenzijdige bestandsoverdracht is ingeschakeld aan de gecontroleerde kant."), ("Authentication Required", "Verificatie vereist"), ("Authenticate", "Verificatie"), - ("web_id_input_tip", "Je kunt een ID invoeren op dezelfde server, directe IP-toegang wordt niet ondersteund in de webclient.\nAls u toegang wilt tot een apparaat op een andere server, voegt u het serveradres toe (@?key=), bijvoorbeeld,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nAls u toegang wilt krijgen tot een apparaat op een publieke server, voer dan \"@public\" in, sleutel is niet nodig voor de publieke server."), + ("web_id_input_tip", "U kunt een ID invoeren op dezelfde server, directe IP-toegang wordt niet ondersteund in de webclient.\nAls u toegang wilt tot een apparaat op een andere server, voegt u het serveradres toe (@?key=), bijvoorbeeld,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nAls u toegang wilt krijgen tot een apparaat op een publieke server, voer dan \"@public\" in, sleutel is niet nodig voor de publieke server."), ("Download", "Downloaden"), ("Upload folder", "Map uploaden"), ("Upload files", "Bestanden uploaden"), ("Clipboard is synchronized", "Klembord is gesynchroniseerd"), ("Update client clipboard", "Klembord van client bijwerken"), - ("Untagged", "Ongemarkeerd"), + ("Untagged", "Ongelabeld"), ("new-version-of-{}-tip", "Er is een nieuwe versie van {} beschikbaar"), ("Accessible devices", "Toegankelijke apparaten"), ("upgrade_remote_rustdesk_client_to_{}_tip", "Upgrade de RustDesk client naar versie {} of nieuwer op de externe computer!"), ("d3d_render_tip", "Wanneer D3D-rendering is ingeschakeld kan het externe scherm op sommige apparaten, zwart zijn."), - ("Use D3D rendering", "Gebruik D3D-rendering"), + ("Use D3D rendering", "D3D-rendering gebruiken"), ("Printer", "Printer"), ("printer-os-requirement-tip", "Windows 10 of hoger is vereist om de uitgaande functie met de printer te laten werken."), ("printer-requires-installed-{}-client-tip", "Om afdrukken op afstand te gebruiken, moet {} geïnstalleerd zijn op dit apparaat."), ("printer-{}-not-installed-tip", "De printer {} is niet geïnstalleerd."), ("printer-{}-ready-tip", "De printer {} is geïnstalleerd en klaar voor gebruik."), - ("Install {} Printer", "Installeer {} Printer"), + ("Install {} Printer", "{} Printer installeren"), ("Outgoing Print Jobs", "Uitgaande Afdruktaken"), ("Incoming Print Jobs", "Inkomende Afdruktaken"), ("Incoming Print Job", "Inkomende Afdruktaak"), - ("use-the-default-printer-tip", "Gebruik de standaard printer"), - ("use-the-selected-printer-tip", "Gebruik de geselecteerde printer"), + ("use-the-default-printer-tip", "Standaard printer gebruiken"), + ("use-the-selected-printer-tip", "Geselecteerde printer gebruiken"), ("auto-print-tip", "Automatisch afdrukken op de geselecteerde printer."), ("print-incoming-job-confirm-tip", "Er werd een afdruktaak ontvangen van een extern apparaat. Moet ik deze lokaal afdrukken?"), ("remote-printing-disallowed-tile-tip", "Afdruk op afstand is verboden"), ("remote-printing-disallowed-text-tip", "Machtigingsinstellingen aan beheerde zijde verhinderen afdrukken op afstand."), ("save-settings-tip", "Instellingen opslaan"), ("dont-show-again-tip", "Dit bericht wordt niet meer weergegeven"), - ("Take screenshot", "Maak een schermafbeelding"), - ("Taking screenshot", "Schermafbeelding maken"), - ("screenshot-merged-screen-not-supported-tip", "Schermafbeeldingen van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), - ("screenshot-action-tip", "Kies wat je met de gemaakte schermafbeelding wilt doen."), + ("Take screenshot", "Schermopname maken"), + ("Taking screenshot", "Schermopname maken..."), + ("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), + ("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."), ("Save as", "Opslaan als"), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), - ("{} Update", "{} Updaten"), + ("{} Update", "{} bijwerken"), ("{}-to-update-tip", "{} zal sluiten en de nieuwe versie installeren."), ("download-new-version-failed-tip", "Fout bij het downloaden. Je kunt het opnieuw proberen of op de knop Downloaden klikken om de applicatie van de officiële website te downloaden en handmatig bij te werken."), ("Auto update", "Automatisch updaten"), ("update-failed-check-msi-tip", "Kan de installatiemethode niet bepalen. Klik op “Downloaden” om de applicatie van de officiële website te downloaden en handmatig bij te werken."), ("websocket_tip", "Het WebSocketprotocol ondersteunt alleen verbindingen met de repeater."), - ("Use WebSocket", "Gebruik het WebSocketprotocol"), + ("Use WebSocket", "WebSocketprotocol gebruiken"), ("Trackpad speed", "Snelheid Trackpad"), ("Default trackpad speed", "Standaardsnelheid Trackpad"), ("Numeric one-time password", "Eenmalig numeriek wachtwoord"), ("Enable IPv6 P2P connection", "IPv6 P2P-verbinding inschakelen"), ("Enable UDP hole punching", "UDP-hole punching inschakelen"), - ("View camera", "Camera bekijken"), + ("View camera", "Camera weergeven"), ("Enable camera", "Camera inschakelen"), ("No cameras", "Geen camera's"), ("view_camera_unsupported_tip", "Het externe apparaat ondersteunt geen cameraweergave."), @@ -709,7 +701,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Supported only in the installed version.", "Alleen ondersteund in de geïnstalleerde versie."), ("elevation_username_tip", "Voer je gebruikersnaam of domeinnaam in"), ("Preparing for installation ...", "Installatie voorbereiden ..."), - ("Show my cursor", "Toon mijn cursor"), + ("Show my cursor", "Mijn cursor weergeven"), ("Scale custom", "Aangepaste schaal"), ("Custom scale slider", "Aangepaste schuifregelaar voor schaal"), ("Decrease", "Verlagen"), @@ -737,31 +729,43 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("rel-mouse-exit-{}-tip", "Druk op {} om af te sluiten."), ("rel-mouse-permission-lost-tip", "De toetsenbordcontrole is uitgeschakeld. De relatieve muismodus is uitgeschakeld."), ("Changelog", "Wijzigingenlogboek"), - ("keep-awake-during-outgoing-sessions-label", "Houd het scherm open tijdens de uitgaande sessies."), - ("keep-awake-during-incoming-sessions-label", "Houd het scherm open tijdens de inkomende sessies."), - ("Continue with {}", "Ga verder met {}"), - ("Display Name", "Naam Weergeven"), - ("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."), - ("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."), - ("Enable privacy mode", "Privacymodus inschakelen"), - ("allow-remote-toolbar-docking-any-edge", "Sta toe om de werkbalk-op-afstand aan de rand van het venster te plaatsen"), + ("keep-awake-during-outgoing-sessions-label", "Scherm actief houden tijdens uitgaande sessies"), + ("keep-awake-during-incoming-sessions-label", "Scherm actief houden tijdens inkomende sessies"), + ("Continue with {}", "Doorgaan met {}"), + ("Display Name", "Weergavenaam"), + ("password-hidden-tip", "Permanent wachtwoord is ingesteld (verborgen)."), + ("preset-password-in-use-tip", "Het vooraf ingestelde wachtwoord is momenteel in gebruik."), + ("Enable privacy mode", "Privémodus inschakelen"), + ("allow-remote-toolbar-docking-any-edge", "Koppelen van de externe werkbalk toestaan aan elke vensterrand"), ("API Token", "API-token"), - ("Deploy", "Implementeren"), + ("Deploy", "Inzetten"), ("Custom ID (optional)", "Aangepast ID (optioneel)"), - ("server_requires_deployment_tip", "De server vereist dat dit apparaat expliciet wordt geïmplementeerd. Nu implementeren?"), - ("The server does not require explicit deployment.", "De server vereist geen expliciete implementatie."), - ("Unknown response.", "Onbekend antwoord."), + ("server_requires_deployment_tip", "De server vereist dat dit apparaat expliciet wordt ingezet. Nu inzetten?"), + ("The server does not require explicit deployment.", "De server vereist geen expliciete inzet."), + ("Unknown response.", "Onbekende respons"), ("wayland-keyboard-input-disabled-tip", "Toetsenbordinvoer toestaan?"), - ("wayland-keyboard-input-consent-tip", "Wat u op deze externe computer typt (inclusief wachtwoorden) kan door andere apps daarop worden gelezen."), + ("wayland-keyboard-input-consent-tip", "Wat u op deze externe computer typt (inclusief wachtwoorden), kan door andere apps erop worden gelezen."), ("wayland-keyboard-input-applies-to-tip", "Deze keuze geldt voor:"), - ("wayland-soft-keyboard-input-label", "Invoer via schermtoetsenbord"), + ("wayland-soft-keyboard-input-label", "Invoer van schermtoetsenbord"), ("wayland-keyboard-input-reset-choice-tip", "Keuze voor toetsenbordinvoer opnieuw instellen"), ("remember-wayland-keyboard-choice-tip", "Niet meer vragen voor deze externe computer"), ("Why this happens", "Waarom dit gebeurt"), - ("Switch display", "Beeldscherm wisselen"), - ("Show monitor switch button on the main toolbar", "Knop voor monitorwisseling weergeven op de hoofdwerkbalk"), - ("Show on the minimized toolbar", "Weergeven op de geminimaliseerde werkbalk"), - ("All monitors", "Alle monitoren"), - ("#{} monitor", "Monitor {}"), + ("Switch display", "Scherm wisselen"), + ("Show monitor switch button on the main toolbar", "Schakelknop van de monitor op de hoofdwerkbalk weergeven"), + ("Show on the minimized toolbar", "Op de geminimaliseerde werkbalk weergeven"), + ("All monitors", "Alle monitors"), + ("#{} monitor", "#{} monitor"), + ("conn-e2ee-unavailable-tip", "Kon end-to-end encryptie niet verifiëren.\nHet externe apparaat is mogelijk nog steeds aan het instellen. Probeer het later opnieuw.\nAls dit blijft gebeuren, kan het zijn dat de server onbetrouwbaar is.\nToch doorgaan?"), + ("ID whitelisting", "Witte lijst met ID's"), + ("Use ID whitelisting", "Witte lijst met ID's gebruiken"), + ("id_whitelist_tip", "Alleen ID's op de witte lijst krijgen toegang tot mijn apparaat"), + ("id_whitelist_wildcard_tip", "Wildcards worden ondersteund: '*' komt overeen met een willekeurig aantal tekens, '?' komt overeen met één teken"), + ("Invalid ID", "Ongeldig ID"), + ("Your ID is blocked by the peer", "Uw ID wordt door de ander geblokkeerd"), + ("Your ip is blocked by the peer", "Uw IP wordt door de ander geblokkeerd"), + ("id_whitelist_caveat_tip", "De ID wordt vermeld door de verbindende client. Deze witte lijst vermindert de zichtbaarheid en vervangt niet het wachtwoord of 2FA."), + ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijv. 192.168.1.0/24"), + ("Continue", "Doorgaan"), + ("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 26c51bbb82a..ea5bd47e588 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Ktoś włącza tryb prywatności, wyjdź"), ("Unsupported", "Niewspierane"), ("Peer denied", "Odmowa dostępu"), - ("Please install plugins", "Zainstaluj wtyczkę"), ("Peer exit", "Wyjście ze zdalnego urządzenia"), ("Failed to turn off", "Nie udało się wyłączyć"), ("Turned off", "Wyłączony"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sygnatura"), ("Copy Fingerprint", "Skopiuj sygnaturę"), ("no fingerprints", "brak sygnatur"), - ("Select a peer", "Wybierz zdalne urządzenie"), - ("Select peers", "Wybierz zdalne urządzenia"), - ("Plugins", "Wtyczki"), - ("Uninstall", "Odinstaluj"), ("Update", "Aktualizuj"), - ("Enable", "Włącz"), - ("Disable", "Wyłącz"), - ("Options", "Opcje"), ("resolution_original_tip", "Oryginalna rozdzielczość"), ("resolution_fit_local_tip", "Dostosuj rozdzielczość lokalną"), ("resolution_custom_tip", "Rozdzielczość niestandardowa"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Pokaż na zminimalizowanym pasku narzędzi"), ("All monitors", "Wszystkie ekrany"), ("#{} monitor", "Ekran {}"), + ("conn-e2ee-unavailable-tip", "Nie można zweryfikować szyfrowania end-to-end.\nUrządzenie zdalne może nadal się konfigurować. Spróbuj ponownie później.\nJeśli problem będzie się powtarzał, serwer może być niezaufany.\nKontynuować mimo to?"), + ("ID whitelisting", "Biała lista ID"), + ("Use ID whitelisting", "Użyj białej listy ID"), + ("id_whitelist_tip", "Zezwalaj na łączenie z tym komputerem tylko z ID znajdujących się na białej liście"), + ("id_whitelist_wildcard_tip", "Obsługiwane są symbole wieloznaczne: '*' odpowiada dowolnej liczbie znaków, '?' dokładnie jednemu znakowi"), + ("Invalid ID", "Nieprawidłowe ID"), + ("Your ID is blocked by the peer", "Twoje ID zostało zablokowane przez drugą stronę"), + ("Your ip is blocked by the peer", "Twój adres IP został zablokowany przez drugą stronę"), + ("id_whitelist_caveat_tip", "ID jest zgłaszane przez łączącego się klienta. Biała lista zmniejsza ekspozycję i nie zastępuje hasła ani 2FA"), + ("whitelist_cidr_tip", "Obsługiwana jest notacja CIDR, na przykład 192.168.1.0/24"), + ("Continue", "Kontynuuj"), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index b5f117be83e..e06b46559bf 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguém activou o modo de privacidade, desligue"), ("Unsupported", "Sem suporte"), ("Peer denied", "Remoto negado"), - ("Please install plugins", "Por favor instale plugins"), ("Peer exit", "Saída do Remoto"), ("Failed to turn off", "Falha ao desligar"), ("Turned off", "Desligado"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão digital"), ("Copy Fingerprint", "Copiar impressão digital"), ("no fingerprints", "Sem impressões digitais"), - ("Select a peer", "Selecionar um destino"), - ("Select peers", "Selecionar destinos"), - ("Plugins", "Plugins"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Ativar"), - ("Disable", "Desativar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Ajustar à resolução local"), ("resolution_custom_tip", "Resolução personalizada"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Mostrar na barra de ferramentas minimizada"), ("All monitors", "Todos os monitores"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "Não foi possível verificar a encriptação de ponta a ponta.\nO dispositivo remoto ainda pode estar a ser configurado. Tente novamente mais tarde.\nSe isto continuar a acontecer, o servidor pode não ser fidedigno.\nContinuar mesmo assim?"), + ("ID whitelisting", "Whitelist de ID"), + ("Use ID whitelisting", "Usar whitelist de ID"), + ("id_whitelist_tip", "Somente IDs na whitelist podem me acessar"), + ("id_whitelist_wildcard_tip", "São suportados carateres universais: '*' corresponde a qualquer número de carateres, '?' a exatamente um caráter"), + ("Invalid ID", "ID inválido"), + ("Your ID is blocked by the peer", "O seu ID está bloqueado pelo dispositivo remoto"), + ("Your ip is blocked by the peer", "O seu IP está bloqueado pelo dispositivo remoto"), + ("id_whitelist_caveat_tip", "O ID é comunicado pelo cliente que se liga. A whitelist reduz a exposição e não substitui a palavra-passe nem o 2FA"), + ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 1c689d42dbc..69adca61ec0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -112,7 +112,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Waiting", "Aguardando"), ("Finished", "Concluído"), ("Speed", "Velocidade"), - ("Custom Image Quality", "Qualidade Visual Personalizada"), + ("Custom Image Quality", "Qualidade de imagem personalizada"), ("Privacy mode", "Modo privado"), ("Block user input", "Bloquear entrada do usuário"), ("Unblock user input", "Desbloquear entrada do usuário"), @@ -122,15 +122,15 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Stretch", "Aumentar"), ("Scrollbar", "Barra de rolagem"), ("ScrollAuto", "Rolagem automática"), - ("Good image quality", "Boa qualidade de imagem"), + ("Good image quality", "Melhor qualidade"), ("Balanced", "Balanceada"), ("Optimize reaction time", "Otimizar tempo de resposta"), - ("Custom", "Personalizado"), + ("Custom", "Personalizada"), ("Show remote cursor", "Mostrar cursor remoto"), ("Show quality monitor", "Exibir monitor de qualidade"), ("Disable clipboard", "Desabilitar área de transferência"), ("Lock after session end", "Bloquear após o fim da sessão"), - ("Insert Ctrl + Alt + Del", "Enviar Ctrl + Alt + Del"), + ("Insert Ctrl + Alt + Del", "Enviar Ctrl+Alt+Del"), ("Insert Lock", "Bloquear computador"), ("Refresh", "Atualizar"), ("ID does not exist", "ID não existe"), @@ -160,7 +160,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accept and Install", "Aceitar e Instalar"), ("End-user license agreement", "Acordo de licença do usuário final"), ("Generating ...", "Gerando ..."), - ("Your installation is lower version.", "Instalação desatualizada"), + ("Your installation is lower version.", "Sua instalação está com uma versão desatualizada."), ("not_close_tcp_tip", "Não feche esta janela enquanto estiver utilizando o túnel"), ("Listening ...", "Escutando ..."), ("Remote Host", "Host Remoto"), @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Alguém habilitou o modo de privacidade, sair"), ("Unsupported", "Não suportado"), ("Peer denied", "Parceiro negou"), - ("Please install plugins", "Por favor instale plugins"), ("Peer exit", "Parceiro saiu"), ("Failed to turn off", "Falha ao desligar"), ("Turned off", "Desligado"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão Digital"), ("Copy Fingerprint", "Copiar Impressão Digital"), ("no fingerprints", "sem Impressões Digitais"), - ("Select a peer", "Selecione um parceiro"), - ("Select peers", "Selecione parceiros"), - ("Plugins", "Plugins"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Habilitar"), - ("Disable", "Desabilitar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Adequar à resolução local"), ("resolution_custom_tip", "Customizar resolução"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Mostrar na barra de ferramentas minimizada"), ("All monitors", "Todas as telas"), ("#{} monitor", "Tela {}"), + ("conn-e2ee-unavailable-tip", "Não foi possível verificar a criptografia de ponta a ponta.\nO dispositivo remoto ainda pode estar sendo configurado. Tente novamente mais tarde.\nSe isso continuar acontecendo, o servidor pode não ser confiável.\nContinuar mesmo assim?"), + ("ID whitelisting", "Lista de IDs permitidos"), + ("Use ID whitelisting", "Utilizar lista de IDs permitidos"), + ("id_whitelist_tip", "Somente IDs confiáveis podem me acessar"), + ("id_whitelist_wildcard_tip", "Curingas são suportados: '*' corresponde a qualquer número de caracteres, '?' a exatamente um caractere"), + ("Invalid ID", "ID inválido"), + ("Your ID is blocked by the peer", "Seu ID foi bloqueado pelo dispositivo remoto"), + ("Your ip is blocked by the peer", "Seu IP foi bloqueado pelo dispositivo remoto"), + ("id_whitelist_caveat_tip", "O ID é informado pelo cliente que se conecta. A lista reduz a exposição e não substitui a senha ou o 2FA"), + ("whitelist_cidr_tip", "A notação CIDR é suportada, por exemplo 192.168.1.0/24"), + ("Continue", "Continuar"), + ("Browser didn't open? Use the url below to sign in.", "O navegador não foi aberto? Use a URL abaixo para fazer login."), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 41d2e42c500..4423d9ddfa3 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Cineva activează modul privat, ieși din"), ("Unsupported", "Neacceptat"), ("Peer denied", "Dispozitiv pereche refuzat"), - ("Please install plugins", "Instalează pluginuri"), ("Peer exit", "Ieșire dispozitiv pereche"), ("Failed to turn off", "Dezactivare nereușită"), ("Turned off", "Închis"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Amprentă digitală"), ("Copy Fingerprint", "Copiază amprenta digitală"), ("no fingerprints", "Nicio amprentă digitală"), - ("Select a peer", "Selectează un dispozitiv pereche"), - ("Select peers", "Selectează dispozitive pereche"), - ("Plugins", "Pluginuri"), - ("Uninstall", "Dezinstalează"), ("Update", "Actualizează"), - ("Enable", "Activează"), - ("Disable", "Dezactivează"), - ("Options", "Opțiuni"), ("resolution_original_tip", "Rezoluție originală"), ("resolution_fit_local_tip", "Adaptează la rezoluția locală"), ("resolution_custom_tip", "Rezoluție personalizată"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Afișează în bara de instrumente minimizată"), ("All monitors", "Toate monitoarele"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "Criptarea end-to-end nu a putut fi verificată.\nDispozitivul la distanță poate fi încă în curs de configurare. Încercați din nou mai târziu.\nDacă acest lucru continuă, serverul poate să nu fie de încredere.\nContinuați oricum?"), + ("ID whitelisting", "Listă de ID-uri autorizate"), + ("Use ID whitelisting", "Folosește lista de ID-uri autorizate"), + ("id_whitelist_tip", "Doar ID-urile autorizate pot accesa acest dispozitiv"), + ("id_whitelist_wildcard_tip", "Sunt acceptate metacaractere: '*' corespunde oricărui număr de caractere, '?' exact unui caracter"), + ("Invalid ID", "ID nevalid"), + ("Your ID is blocked by the peer", "ID-ul tău este blocat de dispozitivul de la distanță"), + ("Your ip is blocked by the peer", "Adresa ta IP este blocată de dispozitivul de la distanță"), + ("id_whitelist_caveat_tip", "ID-ul este raportat de clientul care se conectează. Lista albă reduce expunerea și nu înlocuiește parola sau 2FA"), + ("whitelist_cidr_tip", "Notația CIDR este acceptată, de exemplu 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 2f80a8d447e..6864383c734 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Кто-то включил режим конфиденциальности, выход"), ("Unsupported", "Не поддерживается"), ("Peer denied", "Отклонено удалённым узлом"), - ("Please install plugins", "Установите плагины"), ("Peer exit", "Отключено пользователем"), ("Failed to turn off", "Невозможно отключить"), ("Turned off", "Отключён"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Отпечаток"), ("Copy Fingerprint", "Копировать отпечаток"), ("no fingerprints", "отпечатки отсутствуют"), - ("Select a peer", "Выберите удалённый узел"), - ("Select peers", "Выберите удалённые узлы"), - ("Plugins", "Плагины"), - ("Uninstall", "Удалить"), ("Update", "Обновить"), - ("Enable", "Включить"), - ("Disable", "Отключить"), - ("Options", "Настройки"), ("resolution_original_tip", "Исходное разрешение"), ("resolution_fit_local_tip", "Соответствие локальному разрешению"), ("resolution_custom_tip", "Произвольное разрешение"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Показывать на свёрнутой панели инструментов"), ("All monitors", "Все мониторы"), ("#{} monitor", "Монитор {}"), + ("conn-e2ee-unavailable-tip", "Не удалось проверить сквозное шифрование.\nУдаленное устройство, возможно, еще настраивается. Повторите попытку позже.\nЕсли это повторяется, сервер может быть ненадежным.\nВсе равно продолжить?"), + ("ID whitelisting", "Список разрешённых ID"), + ("Use ID whitelisting", "Использовать белый список ID"), + ("id_whitelist_tip", "Только ID из белого списка могут получить доступ к моему устройству."), + ("id_whitelist_wildcard_tip", "Поддерживаются подстановочные знаки: '*' соответствует любому количеству символов, '?' — ровно одному символу"), + ("Invalid ID", "Неправильный ID"), + ("Your ID is blocked by the peer", "Ваш ID заблокирован удалённым устройством"), + ("Your ip is blocked by the peer", "Ваш IP-адрес заблокирован удалённым устройством"), + ("id_whitelist_caveat_tip", "ID сообщается подключающимся клиентом. Белый список уменьшает поверхность атаки и не заменяет пароль или 2FA"), + ("whitelist_cidr_tip", "Поддерживается нотация CIDR, например 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1bf065fe703..16ecaae87d3 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Calicunu at allutu sa modalidade de riservadesa, essida"), ("Unsupported", "Non suportadu"), ("Peer denied", "Atzessu negadu a su dispositivu remotu"), - ("Please install plugins", "Installa sos cumplementos"), ("Peer exit", "Essida dae su dispostivu remotu"), ("Failed to turn off", "Non faghet a istudare"), ("Turned off", "Istuda"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Còpia firma digitale"), ("no fingerprints", "Peruna firma digitale"), - ("Select a peer", "Seletziona su dispositivu remotu"), - ("Select peers", "Seletziona sos dispositivos remotos"), - ("Plugins", "Cumplementos"), - ("Uninstall", "Disinstalla"), ("Update", "Atualiza"), - ("Enable", "Abìlita"), - ("Disable", "Disabìlita"), - ("Options", "Optziones"), ("resolution_original_tip", "Risolutzione originale"), ("resolution_fit_local_tip", "Adata sa risolutzione locale"), ("resolution_custom_tip", "Risolutzione personalizada"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Mustra in sa barra de aina minimizada"), ("All monitors", "Totu sos ischermos"), ("#{} monitor", "Ischermu {}"), + ("conn-e2ee-unavailable-tip", "No est istadu possìbile verificare sa tzifratzione de punta a punta.\nSu dispositivu remotu podet èssere ancora in fase de configuratzione. Torra a proare prus a tardu.\nSi custu sighit a acontèssere, su server podet non èssere fidadu.\nBoles sighire comente siat?"), + ("ID whitelisting", "ID autorizados"), + ("Use ID whitelisting", "Imprea elencu ID autorizados"), + ("id_whitelist_tip", "Si podent connètere a custa iscrivania petzi sos ID autorizados"), + ("id_whitelist_wildcard_tip", "Sos caràteres jolly sunt suportados: '*' currispondet a cale si siat nùmeru de caràteres, '?' a unu caràtere ebbia"), + ("Invalid ID", "ID non vàlidu"), + ("Your ID is blocked by the peer", "S'ID tuo est blocadu dae s'àtera parte"), + ("Your ip is blocked by the peer", "S'indiritzu IP tuo est blocadu dae s'àtera parte"), + ("id_whitelist_caveat_tip", "S'ID est decraradu dae su cliente chi si connetet. Custu elencu minimat s'espositzione e non sostituit sa crae o su 2FA"), + ("whitelist_cidr_tip", "Sa notatzione CIDR est suportada, pro esempru 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 86b1367bdab..83b5f269a7f 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Niekto zapne režim súkromia, ukončite ho"), ("Unsupported", "Nepodporované"), ("Peer denied", "Peer poprel"), - ("Please install plugins", "Nainštalujte si prosím pluginy"), ("Peer exit", "Peer exit"), ("Failed to turn off", "Nepodarilo sa vypnúť"), ("Turned off", "Vypnutý"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Odtlačok prsta"), ("Copy Fingerprint", "Kopírovať odtlačok prsta"), ("no fingerprints", "žiadne odtlačky prstov"), - ("Select a peer", "Výber partnera"), - ("Select peers", "Výber partnerov"), - ("Plugins", "Pluginy"), - ("Uninstall", "Odinštalovať"), ("Update", "Aktualizovať"), - ("Enable", "Povoliť"), - ("Disable", "Zakázať"), - ("Options", "Možnosti"), ("resolution_original_tip", "Pôvodné rozlíšenie"), ("resolution_fit_local_tip", "Prispôsobiť miestne rozlíšenie"), ("resolution_custom_tip", "Vlastné rozlíšenie"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Zobraziť na minimalizovanom paneli nástrojov"), ("All monitors", "Všetky monitory"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "Nepodarilo sa overiť koncové šifrovanie.\nVzdialené zariadenie sa možno stále nastavuje. Skúste to znova neskôr.\nAk sa to bude opakovať, server nemusí byť dôveryhodný.\nNapriek tomu pokračovať?"), + ("ID whitelisting", "Zoznam povolených ID"), + ("Use ID whitelisting", "Použiť ID whitelisting"), + ("id_whitelist_tip", "Len vymenované ID majú oprávnenie sa pripojiť k vzdialenej správe"), + ("id_whitelist_wildcard_tip", "Podporované sú zástupné znaky: '*' zodpovedá ľubovoľnému počtu znakov, '?' presne jednému znaku"), + ("Invalid ID", "Neplatné ID"), + ("Your ID is blocked by the peer", "Vaše ID je blokované protistranou"), + ("Your ip is blocked by the peer", "Vaša IP adresa je blokovaná protistranou"), + ("id_whitelist_caveat_tip", "ID nahlasuje pripájajúci sa klient. Tento zoznam znižuje vystavenie a nenahrádza heslo ani 2FA"), + ("whitelist_cidr_tip", "Je podporovaný zápis CIDR, napríklad 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs old mode 100755 new mode 100644 index 8f0f50f4d65..7d2e841da1c --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Vklopljen je zasebni način, izhod"), ("Unsupported", "Ni podprto"), ("Peer denied", "Odjemalec zavrnil"), - ("Please install plugins", "Namestite vključke"), ("Peer exit", "Odjemalec se je zaprl"), ("Failed to turn off", "Ni bilo mogoče izklopiti"), ("Turned off", "Izklopljeno"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Prstni odtis"), ("Copy Fingerprint", "Kopiraj prstni odtis"), ("no fingerprints", "ni prstnega odtisa"), - ("Select a peer", "Izberite partnerja"), - ("Select peers", "Izberite partnerje"), - ("Plugins", "Vključki"), - ("Uninstall", "Odstrani"), ("Update", "Posodobi"), - ("Enable", "Omogoči"), - ("Disable", "Onemogoči"), - ("Options", "Možnosti"), ("resolution_original_tip", "Izvirna ločljivost"), ("resolution_fit_local_tip", "Prilagodi lokalni ločljivosti"), ("resolution_custom_tip", "Ločljivost po meri"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Pokaži v pomanjšani orodni vrstici"), ("All monitors", "Vsi zasloni"), ("#{} monitor", "Zaslon {}"), + ("conn-e2ee-unavailable-tip", "Šifriranja od konca do konca ni bilo mogoče preveriti.\nOddaljena naprava se morda še nastavlja. Poskusite znova pozneje.\nČe se to še naprej dogaja, strežnik morda ni zaupanja vreden.\nVseeno nadaljevati?"), + ("ID whitelisting", "Seznam dovoljenih ID-jev"), + ("Use ID whitelisting", "Omogoči seznam dovoljenih ID-jev"), + ("id_whitelist_tip", "Dostop je možen samo z dovoljenih ID-jev"), + ("id_whitelist_wildcard_tip", "Podprti so nadomestni znaki: '*' ustreza poljubnemu številu znakov, '?' natanko enemu znaku"), + ("Invalid ID", "Neveljaven ID"), + ("Your ID is blocked by the peer", "Vaš ID je blokirala oddaljena naprava"), + ("Your ip is blocked by the peer", "Vaš IP je blokirala oddaljena naprava"), + ("id_whitelist_caveat_tip", "ID sporoči odjemalec, ki se povezuje. Seznam zmanjšuje izpostavljenost in ne nadomešča gesla ali 2FA"), + ("whitelist_cidr_tip", "Podprt je zapis CIDR, na primer 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 166ba61e736..e77cf6c4721 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Dikush ka ndezur menyrën e privatësisë , largohu"), ("Unsupported", "Nuk mbështetet"), ("Peer denied", "Peer mohohet"), - ("Please install plugins", "Ju lutemi instaloni shtojcat"), ("Peer exit", "Dalje peer"), ("Failed to turn off", "Dështoi të fiket"), ("Turned off", "I fikur"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Gjurma e gishtit"), ("Copy Fingerprint", "Kopjo gjurmën e gishtit"), ("no fingerprints", "Nuk ka gjurmë gishtash"), - ("Select a peer", "Zgjidh një peer"), - ("Select peers", "Zgjidh peer-at"), - ("Plugins", "Shtojcat"), - ("Uninstall", "Çinstalo"), ("Update", "Përditëso"), - ("Enable", "Aktivizo"), - ("Disable", "Çaktivizo"), - ("Options", "Opsionet"), ("resolution_original_tip", "Rezolucioni origjinal"), ("resolution_fit_local_tip", "Përshtat me rezolucionin lokal"), ("resolution_custom_tip", "Rezolucion i personalizuar"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Shfaq te shiriti i minimizuar i veglave"), ("All monitors", "Të gjithë monitorët"), ("#{} monitor", "Monitori {}"), + ("conn-e2ee-unavailable-tip", "Enkriptimi nga skaji në skaj nuk mund të verifikohej.\nPajisja e largët mund të jetë ende duke u konfiguruar. Provoni përsëri më vonë.\nNëse kjo vazhdon të ndodhë, serveri mund të mos jetë i besueshëm.\nTë vazhdohet gjithsesi?"), + ("ID whitelisting", "Lista e bardhë e ID-ve"), + ("Use ID whitelisting", "Përdor listën e bardhë të ID-ve"), + ("id_whitelist_tip", "Vetëm ID-të e listës së bardhë mund të më aksesojnë."), + ("id_whitelist_wildcard_tip", "Mbështeten karakteret zëvendësuese: '*' përputhet me çdo numër karakteresh, '?' me saktësisht një karakter"), + ("Invalid ID", "ID e pavlefshme"), + ("Your ID is blocked by the peer", "ID-ja juaj është bllokuar nga pala tjetër"), + ("Your ip is blocked by the peer", "IP-ja juaj është bllokuar nga pala tjetër"), + ("id_whitelist_caveat_tip", "ID-ja raportohet nga klienti që lidhet. Lista e bardhë zvogëlon ekspozimin dhe nuk zëvendëson fjalëkalimin ose 2FA"), + ("whitelist_cidr_tip", "Mbështetet shënimi CIDR, për shembull 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 56db4308dd8..93bdc8dd879 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Neko je uključio mod privatnosti, izlaz."), ("Unsupported", "Nepodržano"), ("Peer denied", "Klijent zabranjen"), - ("Please install plugins", "Molimo instalirajte dodatke"), ("Peer exit", "Klijent izašao"), ("Failed to turn off", "Greška kod isključenja"), ("Turned off", "Isključeno"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopiraj otisak"), ("no fingerprints", "Nema otisaka"), - ("Select a peer", "Izaberi klijenta"), - ("Select peers", "Izaberi klijente"), - ("Plugins", "Dodaci"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Omogući"), - ("Disable", "Onemogući"), - ("Options", "Opcije"), ("resolution_original_tip", "Originalna rezolucija"), ("resolution_fit_local_tip", "Prilagodi lokalnoj rezoluciji"), ("resolution_custom_tip", "Prilagođena rezolucija"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Прикажи на умањеној траци са алаткама"), ("All monitors", "Svi monitori"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "Није могуће проверити енд-то-енд шифровање.\nУдаљени уређај се можда још подешава. Покушајте поново касније.\nАко се ово настави, сервер можда није поуздан.\nНаставити свеједно?"), + ("ID whitelisting", "ID pouzdana lista"), + ("Use ID whitelisting", "Koristi listu pouzdanih ID"), + ("id_whitelist_tip", "Samo dozvoljeni ID mi mogu pristupiti"), + ("id_whitelist_wildcard_tip", "Podržani su džoker znakovi: '*' odgovara bilo kom broju znakova, '?' tačno jednom znaku"), + ("Invalid ID", "Nevažeći ID"), + ("Your ID is blocked by the peer", "Vaš ID je blokirala druga strana"), + ("Your ip is blocked by the peer", "Vašu IP adresu je blokirala druga strana"), + ("id_whitelist_caveat_tip", "ID prijavljuje klijent koji se povezuje. Ova lista smanjuje izloženost i ne zamenjuje lozinku ni 2FA"), + ("whitelist_cidr_tip", "Podržan je CIDR zapis, na primer 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 4b7cacd80e8..757034b91a2 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Någon sätter på säkerhetesläge, avsluta"), ("Unsupported", "Stöds inte"), ("Peer denied", "Klienten nekade"), - ("Please install plugins", "Var god installera plugins"), ("Peer exit", "Avsluta klient"), ("Failed to turn off", "Misslyckades med avstängning"), ("Turned off", "Avstängd"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtryck"), ("Copy Fingerprint", "Kopiera fingeravtryck"), ("no fingerprints", "inga fingeravtryck"), - ("Select a peer", "Välj en klient"), - ("Select peers", "Välj klienter"), - ("Plugins", "Plugin"), - ("Uninstall", "Avinstallera"), ("Update", "Uppdatera"), - ("Enable", "Aktivera"), - ("Disable", "Inaktivera"), - ("Options", "Inställningar"), ("resolution_original_tip", "Ursprunglig upplösning"), ("resolution_fit_local_tip", "Anpassa till lokal upplösning"), ("resolution_custom_tip", "Anpassad upplösning"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Visa i det minimerade verktygsfältet"), ("All monitors", "Alla skärmar"), ("#{} monitor", "Skärm {}"), + ("conn-e2ee-unavailable-tip", "End-to-end-kryptering kunde inte verifieras.\nFjärrenheten kan fortfarande konfigureras. Försök igen senare.\nOm detta fortsätter kan servern vara opålitlig.\nFortsätta ändå?"), + ("ID whitelisting", "ID-vitlistning"), + ("Use ID whitelisting", "Använd ID-vitlistning"), + ("id_whitelist_tip", "Bara vitlistade ID:n kan koppla upp till mig"), + ("id_whitelist_wildcard_tip", "Jokertecken stöds: '*' matchar valfritt antal tecken, '?' matchar exakt ett tecken"), + ("Invalid ID", "Ogiltigt ID"), + ("Your ID is blocked by the peer", "Ditt ID är blockerat av motparten"), + ("Your ip is blocked by the peer", "Din IP-adress är blockerad av motparten"), + ("id_whitelist_caveat_tip", "ID:t rapporteras av klienten som ansluter. Vitlistan minskar exponeringen och ersätter inte lösenord eller 2FA"), + ("whitelist_cidr_tip", "CIDR-notation stöds, till exempel 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index ba2a1dcb68f..c5b6cb922bd 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "தனியுரிமை முறை இயக்கப்பட்டது, வெளியேறு"), ("Unsupported", "ஆதரவு இல்லை"), ("Peer denied", "இணையாளர் மறுத்தார்"), - ("Please install plugins", "இணைப்புகளை நிறுவுங்கள்"), ("Peer exit", "இணையாளர் வெளியேறினார்"), ("Failed to turn off", "அணைக்க முடியவில்லை"), ("Turned off", "அணைக்கப்பட்டது"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "கைரேகை"), ("Copy Fingerprint", "கைரேகை நகல்"), ("no fingerprints", "கைரேகைகள் இல்லை"), - ("Select a peer", "பியர் தேர்வு"), - ("Select peers", "பியர்கள் தேர்வு"), - ("Plugins", "இணைப்புகள்"), - ("Uninstall", "நிறுவல் நீக்கு"), ("Update", "புதுப்பி"), - ("Enable", "இயக்கு"), - ("Disable", "அணை"), - ("Options", "விருப்பங்கள்"), ("resolution_original_tip", "அசல் தெளிவுத்திறன்"), ("resolution_fit_local_tip", "உள்ளூர் பொருத்தம்"), ("resolution_custom_tip", "தனிப்பயன் தெளிவுத்திறன்"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "சிறிதாக்கப்பட்ட கருவிப்பட்டையில் காட்டு"), ("All monitors", "அனைத்து மானிட்டர்களும்"), ("#{} monitor", "மானிட்டர் {}"), + ("conn-e2ee-unavailable-tip", "முடிவு-முதல்-முடிவு குறியாக்கத்தை சரிபார்க்க முடியவில்லை.\nதொலை சாதனம் இன்னும் அமைக்கப்பட்டுக் கொண்டிருக்கலாம். பின்னர் மீண்டும் முயற்சிக்கவும்.\nஇது தொடர்ந்து நடந்தால், சேவையகம் நம்பகமற்றதாக இருக்கலாம்.\nஎப்படியும் தொடரவா?"), + ("ID whitelisting", "ID அனுமதிப்பட்டியல்"), + ("Use ID whitelisting", "ID அனுமதிப்பட்டியலைப் பயன்படுத்து"), + ("id_whitelist_tip", "அனுமதிப்பட்டியலில் உள்ள ID-கள் மட்டுமே என்னை அணுக முடியும்"), + ("id_whitelist_wildcard_tip", "வைல்டு கார்டுகள் ஆதரிக்கப்படுகின்றன: '*' எத்தனை எழுத்துகளுக்கும், '?' சரியாக ஒரு எழுத்துக்கும் பொருந்தும்"), + ("Invalid ID", "தவறான ID"), + ("Your ID is blocked by the peer", "உங்கள் ID மறுமுனையால் தடுக்கப்பட்டுள்ளது"), + ("Your ip is blocked by the peer", "உங்கள் IP முகவரி மறுமுனையால் தடுக்கப்பட்டுள்ளது"), + ("id_whitelist_caveat_tip", "இணைக்கும் கிளையண்டே ID-ஐ தெரிவிக்கிறது. அனுமதிப்பட்டியல் வெளிப்பாட்டைக் குறைக்கிறது; கடவுச்சொல் அல்லது 2FA-க்கு மாற்றாகாது"), + ("whitelist_cidr_tip", "CIDR குறியீடு ஆதரிக்கப்படுகிறது, எடுத்துக்காட்டாக 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 2a80bbc4dd1..b1809d900ee 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", ""), ("Unsupported", ""), ("Peer denied", ""), - ("Please install plugins", ""), ("Peer exit", ""), ("Failed to turn off", ""), ("Turned off", ""), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", ""), ("Copy Fingerprint", ""), ("no fingerprints", ""), - ("Select a peer", ""), - ("Select peers", ""), - ("Plugins", ""), - ("Uninstall", ""), ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), ("resolution_original_tip", ""), ("resolution_fit_local_tip", ""), ("resolution_custom_tip", ""), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", ""), ("All monitors", ""), ("#{} monitor", ""), + ("conn-e2ee-unavailable-tip", ""), + ("ID whitelisting", ""), + ("Use ID whitelisting", ""), + ("id_whitelist_tip", ""), + ("id_whitelist_wildcard_tip", ""), + ("Invalid ID", ""), + ("Your ID is blocked by the peer", ""), + ("Your ip is blocked by the peer", ""), + ("id_whitelist_caveat_tip", ""), + ("whitelist_cidr_tip", ""), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 251e4d65bd0..f464e4bbdb2 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "มีใครบางคนเปิดใช้งานโหมดความเป็นส่วนตัว กำลังออก"), ("Unsupported", "ไม่รองรับ"), ("Peer denied", "ถูกปฏิเสธโดยอีกฝั่ง"), - ("Please install plugins", "กรุณาติดตั้งปลั๊กอิน"), ("Peer exit", "อีกฝั่งออก"), ("Failed to turn off", "การปิดล้มเหลว"), ("Turned off", "ปิด"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ลายนิ้วมือ"), ("Copy Fingerprint", "คัดลอกลายนิ้วมือ"), ("no fingerprints", "ไม่มีลายนิ้วมือ"), - ("Select a peer", "เลือกผู้ใช้งาน"), - ("Select peers", "เลือกผู้ใช้งาน"), - ("Plugins", "ปลั๊กอิน"), - ("Uninstall", "ถอนการติดตั้ง"), ("Update", "อัปเดต"), - ("Enable", "เปิดใช้งาน"), - ("Disable", "ปิดใช้งาน"), - ("Options", "ตัวเลือก"), ("resolution_original_tip", "ความละเอียดดั้งเดิม"), ("resolution_fit_local_tip", "ความละเอียดตามต้นทาง"), ("resolution_custom_tip", "ความละเอียดแบบกำหนดเอง"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "แสดงบนแถบเครื่องมือที่ย่อเล็กสุด"), ("All monitors", "จอภาพทั้งหมด"), ("#{} monitor", "จอภาพ {}"), + ("conn-e2ee-unavailable-tip", "ไม่สามารถยืนยันการเข้ารหัสแบบต้นทางถึงปลายทางได้\nอุปกรณ์ระยะไกลอาจยังอยู่ระหว่างการตั้งค่า โปรดลองอีกครั้งภายหลัง\nหากปัญหานี้ยังเกิดขึ้นต่อไป เซิร์ฟเวอร์อาจไม่น่าเชื่อถือ\nต้องการดำเนินการต่อหรือไม่?"), + ("ID whitelisting", "ID ไวท์ลิสต์"), + ("Use ID whitelisting", "ใช้งาน ID ไวท์ลิสต์"), + ("id_whitelist_tip", "อนุญาตเฉพาะการเชื่อมต่อจาก ID ที่ไวท์ลิสต์"), + ("id_whitelist_wildcard_tip", "รองรับสัญลักษณ์แทน: '*' แทนอักขระจำนวนเท่าใดก็ได้, '?' แทนอักขระหนึ่งตัวพอดี"), + ("Invalid ID", "ID ไม่ถูกต้อง"), + ("Your ID is blocked by the peer", "ID ของคุณถูกบล็อกโดยฝั่งตรงข้าม"), + ("Your ip is blocked by the peer", "IP ของคุณถูกบล็อกโดยฝั่งตรงข้าม"), + ("id_whitelist_caveat_tip", "ID ถูกรายงานโดยไคลเอนต์ที่เชื่อมต่อ ไวท์ลิสต์ช่วยลดการเปิดเผยและไม่สามารถใช้แทนรหัสผ่านหรือ 2FA ได้"), + ("whitelist_cidr_tip", "รองรับรูปแบบ CIDR เช่น 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 831ae76f0ce..9b4fcbdc215 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Birisi gizlilik modunu açarsa, çık"), ("Unsupported", "desteklenmiyor"), ("Peer denied", "eş reddedildi"), - ("Please install plugins", "Lütfen eklentileri yükleyin"), ("Peer exit", "Eş çıkışı"), ("Failed to turn off", "Kapatılamadı"), ("Turned off", "Kapatıldı"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Parmak İzi"), ("Copy Fingerprint", "Parmak İzini Kopyala"), ("no fingerprints", "parmak izi yok"), - ("Select a peer", "Bir cihaz seçin"), - ("Select peers", "Cihazları seçin"), - ("Plugins", "Eklentiler"), - ("Uninstall", "Kaldır"), ("Update", "Güncelle"), - ("Enable", "Etkinleştir"), - ("Disable", "Devre Dışı Bırak"), - ("Options", "Seçenekler"), ("resolution_original_tip", "Orijinal çözünürlük"), ("resolution_fit_local_tip", "Yerel çözünürlüğe sığdır"), ("resolution_custom_tip", "Özel çözünürlük"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Simge durumuna küçültülmüş araç çubuğunda göster"), ("All monitors", "Tüm monitörler"), ("#{} monitor", "Monitör {}"), + ("conn-e2ee-unavailable-tip", "Uçtan uca şifreleme doğrulanamadı.\nUzak cihaz hâlâ kuruluyor olabilir. Daha sonra tekrar deneyin.\nBu sorun devam ederse sunucu güvenilir olmayabilir.\nYine de devam edilsin mi?"), + ("ID whitelisting", "İzinli ID listesi"), + ("Use ID whitelisting", "İzinli ID listesini kullan"), + ("id_whitelist_tip", "Bu masaüstüne yalnızca izinli ID'ler bağlanabilir"), + ("id_whitelist_wildcard_tip", "Joker karakterler desteklenir: '*' herhangi bir sayıda karakterle, '?' tam olarak bir karakterle eşleşir"), + ("Invalid ID", "Geçersiz ID"), + ("Your ID is blocked by the peer", "ID'niz karşı taraf tarafından engellendi"), + ("Your ip is blocked by the peer", "IP adresiniz karşı taraf tarafından engellendi"), + ("id_whitelist_caveat_tip", "ID, bağlanan istemci tarafından bildirilir. Bu liste maruziyeti azaltır; parolanın veya 2FA'nın yerini tutmaz"), + ("whitelist_cidr_tip", "CIDR gösterimi desteklenir, örneğin 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 31a2e4f6ae7..88e78bd8fd1 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "有人開啟了隱私模式,退出"), ("Unsupported", "不支援"), ("Peer denied", "對方拒絕"), - ("Please install plugins", "請安裝外掛程式"), ("Peer exit", "對方退出"), ("Failed to turn off", "關閉失敗"), ("Turned off", "已關閉"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指紋"), ("Copy Fingerprint", "複製指紋"), ("no fingerprints", "沒有指紋"), - ("Select a peer", "選擇夥伴"), - ("Select peers", "選擇夥伴"), - ("Plugins", "外掛程式"), - ("Uninstall", "解除安裝"), ("Update", "更新"), - ("Enable", "啟用"), - ("Disable", "停用"), - ("Options", "選項"), ("resolution_original_tip", "原始解析度"), ("resolution_fit_local_tip", "調整成本機解析度"), ("resolution_custom_tip", "自訂解析度"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "在最小化工具列上顯示"), ("All monitors", "所有顯示器"), ("#{} monitor", "{}號顯示器"), + ("conn-e2ee-unavailable-tip", "無法驗證端對端加密。\n遠端裝置可能仍在準備中,請稍後重試。\n如果此問題持續發生,伺服器可能不受信任。\n仍要繼續嗎?"), + ("ID whitelisting", "ID 白名單"), + ("Use ID whitelisting", "只允許白名單上的 ID 進行連線"), + ("id_whitelist_tip", "只有白名單上的 ID 可以存取"), + ("id_whitelist_wildcard_tip", "支援萬用字元:'*' 符合任意數量的字元,'?' 符合單一字元"), + ("Invalid ID", "ID 無效"), + ("Your ID is blocked by the peer", "你的 ID 已被對方封鎖"), + ("Your ip is blocked by the peer", "你的 IP 已被對方封鎖"), + ("id_whitelist_caveat_tip", "ID 由對端用戶端回報,白名單用於減少暴露面,不能取代密碼或 2FA"), + ("whitelist_cidr_tip", "支援 CIDR 寫法,例如 192.168.1.0/24"), + ("Continue", "繼續"), + ("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index e1d15ecd0ad..10fac1a961a 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Хтось вмикає режим конфіденційності, вихід"), ("Unsupported", "Не підтримується"), ("Peer denied", "Відхилено віддаленим пристроєм"), - ("Please install plugins", "Будь ласка, встановіть плагіни"), ("Peer exit", "Вийти з віддаленого пристрою"), ("Failed to turn off", "Не вдалося вимкнути"), ("Turned off", "Вимкнений"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Відбитки пальців"), ("Copy Fingerprint", "Копіювати відбитки пальців"), ("no fingerprints", "немає відбитків пальців"), - ("Select a peer", "Оберіть віддалений пристрій"), - ("Select peers", "Оберіть віддалені пристрої"), - ("Plugins", "Плагіни"), - ("Uninstall", "Видалити"), ("Update", "Оновити"), - ("Enable", "Увімкнути"), - ("Disable", "Вимкнути"), - ("Options", "Опції"), ("resolution_original_tip", "Початкова роздільна здатність"), ("resolution_fit_local_tip", "Припасувати поточну роздільну здатність"), ("resolution_custom_tip", "Користувацька роздільна здатність"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Показувати на згорнутій панелі інструментів"), ("All monitors", "Усі монітори"), ("#{} monitor", "Монітор {}"), + ("conn-e2ee-unavailable-tip", "Не вдалося перевірити наскрізне шифрування.\nВіддалений пристрій, можливо, ще налаштовується. Спробуйте пізніше.\nЯкщо це повторюється, сервер може бути ненадійним.\nПродовжити все одно?"), + ("ID whitelisting", "Список дозволених ID"), + ("Use ID whitelisting", "Використовувати білий список ID"), + ("id_whitelist_tip", "Лише ID з білого списку можуть отримати доступ до мене"), + ("id_whitelist_wildcard_tip", "Підтримуються символи підстановки: '*' відповідає будь-якій кількості символів, '?' — рівно одному символу"), + ("Invalid ID", "Неправильний ID"), + ("Your ID is blocked by the peer", "Ваш ID заблоковано віддаленим пристроєм"), + ("Your ip is blocked by the peer", "Вашу IP-адресу заблоковано віддаленим пристроєм"), + ("id_whitelist_caveat_tip", "ID повідомляється клієнтом, що підключається. Білий список зменшує поверхню атаки і не замінює пароль або 2FA"), + ("whitelist_cidr_tip", "Підтримується нотація CIDR, наприклад 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index ea5f219a795..46faff12f91 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Chế độ riêng tư đã được bật, thoát"), ("Unsupported", "Không hỗ trợ"), ("Peer denied", "Đối tác từ chối"), - ("Please install plugins", "Vui lòng cài đặt plugin"), ("Peer exit", "Đối tác đã thoát"), ("Failed to turn off", "Không thể tắt"), ("Turned off", "Đã tắt"), @@ -483,14 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Dấu vân tay"), ("Copy Fingerprint", "Sao chép fingerprint"), ("no fingerprints", "không có fingerprint"), - ("Select a peer", "Chọn một đối tác"), - ("Select peers", "Chọn các đối tác"), - ("Plugins", "Plugin"), - ("Uninstall", "Gỡ cài đặt"), ("Update", "Cập nhật"), - ("Enable", "Bật"), - ("Disable", "Tắt"), - ("Options", "Tùy chọn"), ("resolution_original_tip", "Độ phân giải gốc"), ("resolution_fit_local_tip", "Vừa với máy cục bộ"), ("resolution_custom_tip", "Độ phân giải tùy chỉnh"), @@ -763,5 +755,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Hiển thị trên thanh công cụ thu nhỏ"), ("All monitors", "Tất cả màn hình"), ("#{} monitor", "Màn hình {}"), + ("conn-e2ee-unavailable-tip", "Không thể xác minh mã hóa đầu cuối.\nThiết bị từ xa có thể vẫn đang được thiết lập. Hãy thử lại sau.\nNếu điều này tiếp tục xảy ra, máy chủ có thể không đáng tin cậy.\nVẫn tiếp tục?"), + ("ID whitelisting", "Danh sách trắng ID"), + ("Use ID whitelisting", "Sử dụng danh sách trắng ID"), + ("id_whitelist_tip", "Chỉ ID trong danh sách trắng mới có thể truy cập"), + ("id_whitelist_wildcard_tip", "Hỗ trợ ký tự đại diện: '*' khớp với số lượng ký tự bất kỳ, '?' khớp với đúng một ký tự"), + ("Invalid ID", "ID không hợp lệ"), + ("Your ID is blocked by the peer", "ID của bạn đã bị phía bên kia chặn"), + ("Your ip is blocked by the peer", "IP của bạn đã bị phía bên kia chặn"), + ("id_whitelist_caveat_tip", "ID do máy khách kết nối tự khai báo. Danh sách trắng giúp giảm mức độ lộ diện và không thay thế mật khẩu hay 2FA"), + ("whitelist_cidr_tip", "Hỗ trợ ký hiệu CIDR, ví dụ 192.168.1.0/24"), + ("Continue", ""), + ("Browser didn't open? Use the url below to sign in.", ""), ].iter().cloned().collect(); } diff --git a/src/lib.rs b/src/lib.rs index 5621d5e2a68..20d5d6aabd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,6 @@ pub mod ipc; #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] pub mod ui; @@ -38,21 +37,15 @@ pub mod flutter; pub mod flutter_ffi; use common::*; mod auth_2fa; -#[cfg(feature = "cli")] -pub mod cli; #[cfg(not(target_os = "ios"))] mod clipboard; -#[cfg(not(any(target_os = "android", target_os = "ios", feature = "cli")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod core_main; mod custom_server; mod lang; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod port_forward; -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub mod plugin; - #[cfg(not(any(target_os = "android", target_os = "ios")))] mod tray; diff --git a/src/main.rs b/src/main.rs index 9bc90a8fab2..dbafa3d0c7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,6 @@ fn main() { #[cfg(not(any( target_os = "android", target_os = "ios", - feature = "cli", feature = "flutter" )))] fn main() { @@ -32,73 +31,3 @@ fn main() { } common::global_clean(); } - -#[cfg(feature = "cli")] -fn main() { - if !common::global_init() { - return; - } - use clap::App; - use hbb_common::log; - let args = format!( - "-p, --port-forward=[PORT-FORWARD-OPTIONS] 'Format: remote-id:local-port:remote-port[:remote-host]' - -c, --connect=[REMOTE_ID] 'test only' - -k, --key=[KEY] '' - -s, --server=[] 'Start server'", - ); - let matches = App::new("rustdesk") - .version(crate::VERSION) - .author("Purslane Ltd") - .about("RustDesk command line tool") - .args_from_usage(&args) - .get_matches(); - use hbb_common::{config::LocalConfig, env_logger::*}; - init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info")); - if let Some(p) = matches.value_of("port-forward") { - let options: Vec = p.split(":").map(|x| x.to_owned()).collect(); - if options.len() < 3 { - log::error!("Wrong port-forward options"); - return; - } - let mut port = 0; - if let Ok(v) = options[1].parse::() { - port = v; - } else { - log::error!("Wrong local-port"); - return; - } - let mut remote_port = 0; - if let Ok(v) = options[2].parse::() { - remote_port = v; - } else { - log::error!("Wrong remote-port"); - return; - } - let mut remote_host = "localhost".to_owned(); - if options.len() > 3 { - remote_host = options[3].clone(); - } - common::test_rendezvous_server(); - common::test_nat_type(); - let key = matches.value_of("key").unwrap_or("").to_owned(); - let token = LocalConfig::get_option("access_token"); - cli::start_one_port_forward( - options[0].clone(), - port, - remote_host, - remote_port, - key, - token, - ); - } else if let Some(p) = matches.value_of("connect") { - common::test_rendezvous_server(); - common::test_nat_type(); - let key = matches.value_of("key").unwrap_or("").to_owned(); - let token = LocalConfig::get_option("access_token"); - cli::connect_test(p, key, token); - } else if let Some(p) = matches.value_of("server") { - log::info!("id={}", hbb_common::config::Config::get_id()); - crate::start_server(true, false); - } - common::global_clean(); -} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9a4bb37ecb2..f67952e9bb6 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,15 @@ use super::{gtk_sudo, CursorData, ResultType}; use desktop::Desktop; pub use hbb_common::platform::linux::*; + +#[cfg(feature = "drm")] +pub fn dispatch_wayland_display_probe() { + use std::ffi::OsStr; + + if std::env::args_os().nth(1).as_deref() == Some(OsStr::new(WAYLAND_DISPLAY_PROBE_ARG)) { + wayland_display_probe_child_main(); + } +} use hbb_common::{ allow_err, anyhow::anyhow, @@ -43,8 +52,37 @@ const TERM_XTERM_256COLOR: &str = "xterm-256color"; const TERM_SCREEN_256COLOR: &str = "screen-256color"; const TERM_XTERM: &str = "xterm"; +#[cfg(feature = "drm")] +lazy_static::lazy_static! { + /// Only for per-frame callers; see `is_login_screen_wayland_cached`. + /// Own block because `#[cfg]` on one item inside a shared one breaks the macro. + static ref IS_LOGIN_SCREEN_WAYLAND: bool = is_login_screen_wayland(); +} + lazy_static::lazy_static! { - pub static ref IS_X11: bool = hbb_common::platform::linux::is_x11_or_headless(); + /// `is_x11_or_headless()` answers x11 at a Wayland greeter, which the portal could not + /// serve but the DRM path can. Unmemoised lookup on purpose: this may run mid-boot, and + /// a "no" cached that early would be wrong for the rest of the process. + pub static ref IS_X11: bool = { + let x11 = hbb_common::platform::linux::is_x11_or_headless(); + #[cfg(feature = "drm")] + { + if x11 && !display_server_forced() && is_login_screen_wayland() { + log::info!( + "drm: seat0 is a Wayland login screen that reads as x11 upstream; \ + treating it as Wayland so the DRM path is not disabled at the one \ + screen it exists for" + ); + false + } else { + x11 + } + } + #[cfg(not(feature = "drm"))] + { + x11 + } + }; // Cache for TERM value - once TERM_XTERM_256COLOR is found, reuse it directly static ref CACHED_TERM: std::sync::Mutex> = std::sync::Mutex::new(None); static ref DATABASE_XTERM_256COLOR: Option = { @@ -208,6 +246,34 @@ pub fn is_login_screen_wayland() -> bool { is_gdm_user(&values[1]) && get_display_server_of_session(&values[0]) == DISPLAY_SERVER_WAYLAND } +/// An explicit `RUSTDESK_FORCED_DISPLAY_SERVER` is an operator override, and the root service +/// forwards it to the per-user server on purpose: the greeter correction may only fix an +/// AUTO-detected answer, never argue with the operator — a half-applied override would leave +/// `get_display_server()` and the DRM routing gates disagreeing with each other. +#[cfg(feature = "drm")] +pub(crate) fn display_server_forced() -> bool { + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER").is_ok() +} + +/// X11 as far as the DRM path is concerned: a Wayland greeter is not, unless the operator +/// forced the display server. +/// +/// Both halves unmemoised, for the retry loops that must keep asking until seat0 can be named. +#[cfg(feature = "drm")] +pub fn is_x11_for_drm() -> bool { + scrap::is_x11() && (display_server_forced() || !is_login_screen_wayland()) +} + +/// Memoised `is_login_screen_wayland`, for per-frame callers that must not run `loginctl`. +/// +/// Only from the per-session `--server`: it is spawned after the session is identified, so the +/// answer is settled. Anything that can run mid-boot must use the uncached form. +#[cfg(feature = "drm")] +#[inline] +pub fn is_login_screen_wayland_cached() -> bool { + *IS_LOGIN_SCREEN_WAYLAND +} + #[inline] fn sleep_millis(millis: u64) { std::thread::sleep(Duration::from_millis(millis)); @@ -361,6 +427,30 @@ pub fn get_focused_display(displays: Vec) -> Option { } pub fn get_cursor() -> ResultType> { + // DRM/KMS capture: the hardware cursor arrives over the `_drm` stream, not from XFixes. + // + // The MEMOISED `is_x11()` here, deliberately, unlike the capture-path callers that take the + // unmemoised `scrap::is_x11()` because this one latches on first use. The tradeoff is the other + // way round at cursor cadence: the unmemoised form forks `loginctl` per call, and this runs on + // every cursor poll. A latch that guessed wrong costs a cursor served by the wrong source until + // the process restarts, not a capture that cannot start -- and by the time a cursor is being + // polled there is a live session, which is the case the latch reads correctly. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(id) = crate::server::drm_capturer::drm_cursor_id() { + // In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays; + // when the pointer sits on a PipeWire-served display every DRM stream reports the hidden + // sentinel. Returning that sentinel here would hide the cursor globally, including on the + // PipeWire display where it is still visible, so only report a hidden DRM cursor when it + // is authoritative -- a pure-DRM session. A visible DRM cursor is always authoritative; + // otherwise fall through to the normal cursor path. + if id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + return Ok(Some(id)); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(d) = conn.try_borrow_mut() { @@ -379,6 +469,32 @@ pub fn get_cursor() -> ResultType> { } pub fn get_cursor_data(hcursor: u64) -> ResultType { + // DRM/KMS capture: return the latest hardware-cursor snapshot from the `_drm` stream. Its id may + // have advanced past `hcursor` between get_cursor() and here, so return the latest rather than + // bailing (which would trigger a MouseCursorService backoff). + // + // Memoised `is_x11()` on purpose, for the reason spelled out in `get_cursor()`; the two must + // agree anyway, since a caller that took the DRM branch there has to take it here. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(c) = crate::server::drm_capturer::drm_cursor() { + // See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In + // a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served + // by the normal path instead of being hidden everywhere. + if c.id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + let mut cd: CursorData = Default::default(); + cd.id = c.id; + cd.width = c.width; + cd.height = c.height; + cd.hotx = c.hotx; + cd.hoty = c.hoty; + cd.colors = c.colors.into(); + return Ok(cd); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(ref mut d) = conn.try_borrow_mut() { @@ -646,6 +762,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType> { if !desktop.dbus.is_empty() { envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone())); } + if let Ok(forced_display_server) = + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") + { + if !forced_display_server.is_empty() { + envs.push(( + "RUSTDESK_FORCED_DISPLAY_SERVER", + forced_display_server, + )); + } + } envs.push(( "TERM", get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()), @@ -670,6 +796,40 @@ fn start_server(desktop: Option<&Desktop>, server: &mut Option) { } } +/// Whether a just-spawned `--server` is still running after a short grace period, taking ownership of +/// the corpse (clearing `server`) when it is not. `start_server` reports only whether the SPAWN +/// succeeded, which is not the same question: a child that execs and exits immediately still leaves +/// `Some(child)` behind. +/// +/// A child that exits is detected as soon as it does; a healthy one costs the full grace, once per +/// start. A server that dies LATER than this is a different (transient) failure, and the restart +/// throttle in `should_start_server` already bounds that case. +#[cfg(feature = "drm")] +fn server_survived_grace(server: &mut Option) -> bool { + const GRACE: Duration = Duration::from_millis(1000); + const STEP_MS: u64 = 100; + let Some(ps) = server.as_mut() else { + return false; // spawn itself failed + }; + let deadline = Instant::now() + GRACE; + while Instant::now() < deadline { + match ps.try_wait() { + Ok(Some(status)) => { + log::warn!("--server exited {status} within {GRACE:?} of starting"); + *server = None; + return false; + } + Ok(None) => sleep_millis(STEP_MS), + // We cannot tell; treat it as alive rather than tearing down a possibly healthy child. + Err(err) => { + log::error!("error waiting on the just-started --server: {err}"); + return true; + } + } + } + true +} + fn stop_server(server: &mut Option) { if let Some(mut ps) = server.take() { allow_err!(ps.kill()); @@ -800,6 +960,29 @@ pub fn start_os_service() { allow_err!(crate::ipc::start(crate::POSTFIX_SERVICE)); }); + // DRM/KMS capture producer (opt-in `drm` feature): a dedicated thread + runtime that streams + // scanout frames to the user `--server` over the `_drm` service-scoped channel. Runs here + // because this process is the root service that already holds CAP_SYS_ADMIN for the in-process + // (direct-mode) libdrmtap read. + // + // Builder, like every other thread this feature starts: `thread::spawn` PANICS if the thread + // cannot be created (EAGAIN under a thread-count or memory limit), and here that panic would + // unwind out of `start_os_service` -- taking down the root service itself, for a feature whose + // failure should only cost DRM capture. Losing the producer leaves the consumer to fall back to + // PipeWire/X11, which is the same path a host without the feature takes. + #[cfg(feature = "drm")] + if let Err(err) = std::thread::Builder::new() + .name("drm-producer".into()) + .spawn(|| { + crate::ipc::start_drm(); + }) + { + log::warn!( + "failed to spawn the drm capture producer thread: {err}; DRM capture is off for \ + this boot and the consumer falls back to PipeWire/X11" + ); + } + let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let (mut display, mut xauth): (String, String) = ("".to_owned(), "".to_owned()); @@ -838,7 +1021,38 @@ pub fn start_os_service() { ) { stop_subprocess(); force_stop_server(); + // Run the login-screen --server as the active seat0 session user (the greeter + // account) rather than root, so the DRM capture GPU/EGL convert never loads the + // vendor GPU userspace in a privileged process. is_login_wayland() matches a GDM or + // SDDM Wayland greeter (is_gdm_user covers both), and desktop.uid is that greeter's + // uid, so this drops to whichever greeter owns seat0. A greeter is_gdm_user does not + // recognize (e.g. LightDM) never reaches this branch -- it takes the unprivileged + // else-branch below already. A genuine root graphical session (username=="root") + // has no lower uid to drop to, so it stays root. The whole branch is gated on the drm + // feature, so the drm-off build is upstream's single `start_server(None, ..)` line. + #[cfg(not(feature = "drm"))] start_server(None, &mut server); + #[cfg(feature = "drm")] + if desktop.username != "root" && !desktop.uid.is_empty() { + start_server(Some(&desktop), &mut server); + // If dropping to the greeter uid did not produce a RUNNING server, fall back to a + // root --server so the login screen stays remotable instead of looping on a + // failing greeter spawn. This pays the GPU-in-root tradeoff only on that failure + // path, never in the normal greeter case. Liveness, not just spawn success: a + // greeter account that cannot actually run it (a nologin shell, a hardened home, + // no writable config dir) leaves a child that exits at once, and the loop above + // notices only that the child is gone and respawns it, forever, without ever + // reaching this fallback -- so the login screen becomes permanently un-remotable + // on a host where it used to work. + if !server_survived_grace(&mut server) { + log::warn!( + "greeter --server did not stay up; falling back to a root --server" + ); + start_server(None, &mut server); + } + } else { + start_server(None, &mut server); + } } } else if desktop.username != "" { // try kill subprocess "--server" @@ -914,9 +1128,23 @@ pub fn get_active_userid() -> String { #[inline] /// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. pub fn get_active_userid_fresh() -> String { + // A Wayland greeter owns seat0 while it is up and the DRM backend serves it, so a uid gate that + // cannot see it rejects the greeter's own `--server`. `Desktop::refresh` reads it the same way. + #[cfg(feature = "drm")] + return get_values_of_seat0_with_gdm_wayland(&[1])[0].clone(); + #[cfg(not(feature = "drm"))] get_values_of_seat0(&[1])[0].clone() } +#[inline] +/// The cached active uid as a number, or `None` when the cache is empty. Unlike `get_active_userid` +/// this NEVER falls back to a blocking `loginctl` seat0 lookup, so it is safe to call on an async +/// runtime thread and on a hot path (e.g. per-frame re-auth): a cache miss returns `None` for the +/// caller to treat as "active session momentarily unknown" rather than stalling on a subprocess. +pub fn get_active_userid_cached() -> Option { + get_active_user_id_name_from_cache().and_then(|(uid, _)| uid.parse::().ok()) +} + fn get_cm() -> bool { // We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems. if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() { @@ -1929,6 +2157,22 @@ mod desktop { self.display = "".to_owned(); self.xauth = "".to_owned(); self.is_rustdesk_subprocess = false; + // Resolve HOME even on this path. Upstream returned without it because nothing then + // consumed a login-Wayland Desktop, but the drm build starts a `--server` as the + // greeter uid here, and a child with no HOME has nowhere to put its config. The + // compositor variables (WAYLAND_DISPLAY, DBUS, DISPLAY, XAUTHORITY) are left blank + // on purpose and are NOT an oversight: the drm capture path talks to the root + // service over `_drm` and to a render node, never to the compositor or the portal, + // which is the entire reason it works at a login screen. `try_start_server_` skips + // empty entries, so the greeter child simply does not get them. + // + // `is_login_wayland` needs `is_gdm_user(username)`, and a current GDM runs its + // greeter as `gdm-greeter`, which that helper does not match -- measured on the + // test host, where the greeter server therefore takes the branch below and gets a + // fully populated environment. This is for the display managers whose greeter user + // does match. + #[cfg(feature = "drm")] + self.get_home(); return; } @@ -1972,18 +2216,167 @@ mod desktop { } } -pub struct WakeLock(Option); +/// A session-bus idle-inhibit interface, tried in order; the first that answers wins. +/// `org.freedesktop.ScreenSaver` is absent on purpose: that is the one `keepawake` already tried. +struct SessionInhibitTarget { + dest: &'static str, + path: &'static str, + iface: &'static str, + /// GNOME takes `(app_id, xid, reason, flags)`; PowerManagement takes `(app, reason)`. + gnome_shape: bool, + uninhibit: &'static str, +} + +/// `org.gnome.SessionManager.Inhibit` flag 8 = idle only; logout/switch-user/suspend would take +/// away actions the person at the machine should keep. +const GNOME_INHIBIT_IDLE: u32 = 8; + +const SESSION_INHIBIT_TARGETS: &[SessionInhibitTarget] = &[ + // Measured on a GDM greeter: output held 129.9 s with the inhibit, 30.3 s without. + SessionInhibitTarget { + dest: "org.gnome.SessionManager", + path: "/org/gnome/SessionManager", + iface: "org.gnome.SessionManager", + gnome_shape: true, + uninhibit: "Uninhibit", + }, + // What powerdevil and xfce4-power-manager implement. NOT tested here; costs one failed call + // where absent, and the log below names every interface tried. + SessionInhibitTarget { + dest: "org.freedesktop.PowerManagement", + path: "/org/freedesktop/PowerManagement/Inhibit", + iface: "org.freedesktop.PowerManagement.Inhibit", + gnome_shape: false, + uninhibit: "UnInhibit", + }, +]; + +/// Idle inhibit for the case `keepawake` cannot serve: it inhibits `org.freedesktop.ScreenSaver`, +/// which a GDM greeter bus neither provides nor can activate, so its `create()` fails outright. +/// The connection is kept because the inhibit is bound to it: dropping it releases the inhibit. +struct SessionIdleInhibit { + conn: dbus::blocking::Connection, + target: &'static SessionInhibitTarget, + cookie: u32, +} + +impl SessionIdleInhibit { + fn new(reason: &str) -> Option { + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::info!("wakelock: no session bus for the idle inhibit fallback ({err})"); + return None; + } + }; + let app = crate::get_app_name(); + let mut refused = Vec::new(); + for target in SESSION_INHIBIT_TARGETS { + let res: Result<(u32,), dbus::Error> = { + let proxy = conn.with_proxy( + target.dest, + target.path, + std::time::Duration::from_secs(3), + ); + if target.gnome_shape { + // Inhibit(s app_id, u xid, s reason, u flags) -> u cookie; xid 0 = no window. + proxy.method_call( + target.iface, + "Inhibit", + (app.clone(), 0u32, reason.to_owned(), GNOME_INHIBIT_IDLE), + ) + } else { + proxy.method_call(target.iface, "Inhibit", (app.clone(), reason.to_owned())) + } + }; + match res { + Ok((cookie,)) => { + log::info!( + "wakelock: holding a {} idle inhibit (cookie {cookie})", + target.dest + ); + return Some(Self { + conn, + target, + cookie, + }); + } + Err(err) => refused.push(format!("{}: {err}", target.dest)), + } + } + // Name every interface tried and why it failed: on an untested desktop this log is what + // turns "the screen still blanks" into a report naming the missing interface. + log::info!( + "wakelock: no session idle inhibitor answered, so the compositor may still blank this \ + screen ({})", + refused.join("; ") + ); + None + } +} + +impl Drop for SessionIdleInhibit { + fn drop(&mut self) { + let proxy = self.conn.with_proxy( + self.target.dest, + self.target.path, + std::time::Duration::from_secs(3), + ); + // Best effort: the session manager ties the inhibit to the caller's bus name, so dropping + // `conn` below releases it even if this call does not get through. + let res: Result<(), dbus::Error> = + proxy.method_call(self.target.iface, self.target.uninhibit, (self.cookie,)); + if let Err(err) = res { + log::debug!("wakelock: releasing the idle inhibit by closing the bus instead ({err})"); + } + } +} + +pub struct WakeLock(Option, Option); impl WakeLock { pub fn new(display: bool, idle: bool, sleep: bool) -> Self { - WakeLock( - keepawake::Builder::new() - .display(display) - .idle(idle) - .sleep(sleep) - .create() - .ok(), - ) + match keepawake::Builder::new() + .display(display) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => WakeLock(Some(handle), None), + Err(err) => { + // Not `.ok()`: a discarded error is how a login screen ran with no inhibitor at + // all and nobody noticed. + log::info!("wakelock: keepawake could not take the inhibit ({err})"); + // keepawake asks for the ScreenSaver inhibit first and abandons the whole request + // if it fails, losing the logind idle/sleep inhibits that stop the HOST suspending + // mid-session. Re-ask without the display part: those are on the system bus. + let system = if idle || sleep { + match keepawake::Builder::new() + .display(false) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => Some(handle), + Err(err) => { + log::info!( + "wakelock: the logind idle/sleep inhibit did not come back \ + either ({err})" + ); + None + } + } + } else { + None + }; + let session = if display { + SessionIdleInhibit::new("incoming session") + } else { + None + }; + WakeLock(system, session) + } + } } } diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs index 4cfde61a22e..573dfa018c8 100644 --- a/src/platform/linux_desktop_manager.rs +++ b/src/platform/linux_desktop_manager.rs @@ -17,22 +17,34 @@ use std::{ path::Path, process::{Child, Command}, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc::{sync_channel, SyncSender}, Arc, Mutex, }, time::{Duration, Instant}, }; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Seat0Snapshot { + sequence: usize, + username: Option>, +} + lazy_static::lazy_static! { static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); static ref DESKTOP_MANAGER: Arc>> = Arc::new(Mutex::new(None)); + /// Last settled "who owns seat0" answer, for the PRE-AUTH path only; see `is_headless`. + static ref SEAT0_SNAPSHOT: Mutex = Mutex::new(Seat0Snapshot::default()); + static ref SEAT0_NEXT_REFRESH: Mutex> = Mutex::new(None); } +static SEAT0_REFRESH_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +const FIRST_SEAT0_QUERY_SEQUENCE: usize = 1; +static SEAT0_QUERY_SEQUENCE: AtomicUsize = AtomicUsize::new(FIRST_SEAT0_QUERY_SEQUENCE); +const SEAT0_REFRESH_INTERVAL: Duration = Duration::from_secs(1); + #[derive(Debug)] struct DesktopManager { - seat0_username: String, - seat0_display_server: String, child_username: String, child_exit: Arc, is_child_running: Arc, @@ -53,6 +65,9 @@ pub fn start_xdesktop() { std::thread::spawn(|| { DesktopManager::recover_orphaned_session(); *DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new()); + // Seed the pre-auth snapshot now, off the connection path: without this the first + // connection of every server process would read no snapshot at all. + kick_seat0_refresh(); let interval = time::Duration::from_millis(super::SERVICE_INTERVAL); DESKTOP_RUNNING.store(true, Ordering::SeqCst); @@ -154,6 +169,7 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { .to_owned() } else { let username = get_username(); + log::debug!("try_start_desktop, username: {}, _username: {}", &username, &_username); if username == _username { // No need to verify password here. return "".to_owned(); @@ -195,9 +211,12 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { } fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { + // Seat0 is read BEFORE the manager lock: the lookup runs loginctl, and at a greeter the DRM + // probe, and holding DESKTOP_MANAGER across those waits serializes every other caller. + let seat0_username = refresh_seat0_snapshot(); let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); if let Some(desktop_manager) = &mut (*desktop_manager) { - if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() { + if let Some(seat0_username) = seat0_username { return Ok((seat0_username, true)); } @@ -219,27 +238,188 @@ fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), } #[inline] +/// The PRE-AUTH form: connection setup asks this before the peer has authenticated, so it must +/// not run loginctl or wait on the DRM probe (an unauthenticated client would occupy a worker, +/// and every connection would serialize behind the same lookup). It answers from the last +/// settled snapshot and refreshes it off-thread; the decisions that ENFORCE — `get_username`, +/// `try_start_x_session` — stay fresh. pub fn is_headless() -> bool { - DESKTOP_MANAGER - .lock() - .unwrap() - .as_ref() - .map_or(false, |manager| { - manager.get_supported_display_seat0_username().is_none() + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return false; + } + let cached = SEAT0_SNAPSHOT.lock().unwrap().username.clone(); + kick_seat0_refresh(); + // No snapshot yet answers NOT headless: guessing in the headless direction would show the + // OS-login flow over a live Wayland greeter, which reads as an empty seat0 too. A false + // only delays the headless flow until the first refresh lands, and the snapshot is seeded + // from `start_xdesktop`, so the empty window is server start, not every connection. + cached.map_or(false, |answer| answer.is_none()) +} + +/// A free function on purpose: it runs loginctl (and at a greeter the DRM probe), so no caller +/// may reach it while holding `DESKTOP_MANAGER` — that mutex held across subprocess or IPC waits +/// serializes every connection behind one slow lookup. +fn supported_display_seat0_username() -> Option { + // Read seat0 fresh on every query: the values cached in `DesktopManager::new()` go stale + // across a logout or fast-user-switch, which would skip the greeter probe below and hand + // back the previous session owner. Queried here and not in `new()` also because the read + // there hides greeters. + let seat0_values = get_values_of_seat0(&[0, 2]); + let seat0_username = seat0_values[1].clone(); + #[cfg(feature = "drm")] + if seat0_username.is_empty() || is_gdm_user(&seat0_username) { + if let Some(username) = drm_login_screen_seat0_username() { + return Some(username); + } + } + if seat0_username.is_empty() { + None + } else if is_gdm_user(&seat0_username) + && get_display_server_of_session(&seat0_values[0]) == DISPLAY_SERVER_WAYLAND + { + None + } else { + Some(seat0_username) + } +} + +fn select_newer_seat0_snapshot(current: Seat0Snapshot, candidate: Seat0Snapshot) -> Seat0Snapshot { + if candidate.sequence > current.sequence { + candidate + } else { + current + } +} + +fn refresh_seat0_snapshot() -> Option { + let sequence = SEAT0_QUERY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let fresh = supported_display_seat0_username(); + let candidate = Seat0Snapshot { + sequence, + username: Some(fresh.clone()), + }; + let mut snapshot = SEAT0_SNAPSHOT.lock().unwrap(); + let current = std::mem::take(&mut *snapshot); + *snapshot = select_newer_seat0_snapshot(current, candidate); + fresh +} + +/// Clears the single-flight flag on every exit, including a panic in the refresh thread; without +/// it a panic would freeze `is_headless` on a stale snapshot for the process lifetime. +struct Seat0RefreshGuard; +impl Drop for Seat0RefreshGuard { + fn drop(&mut self) { + SEAT0_REFRESH_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Refresh the snapshot off-thread with a process-wide rate limit and single-flight. +fn kick_seat0_refresh() { + let now = Instant::now(); + { + let mut next_refresh = SEAT0_NEXT_REFRESH.lock().unwrap(); + let (next, should_refresh) = schedule_seat0_refresh(*next_refresh, now); + *next_refresh = next; + if !should_refresh { + return; + } + } + if SEAT0_REFRESH_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let guard = Seat0RefreshGuard; + if let Err(err) = std::thread::Builder::new() + .name("seat0-snapshot".into()) + .spawn(move || { + let _guard = guard; + let _ = refresh_seat0_snapshot(); }) + { + log::warn!("Could not spawn the seat0 snapshot refresh thread: {err}"); + } +} + +/// The Wayland greeter on seat0, if the DRM backend can capture and inject into it. +#[cfg(feature = "drm")] +fn drm_login_screen_seat0_username() -> Option { + // An operator-forced X11 wins over greeter adoption: adopting would rebuild exactly the + // inconsistency the forced gate exists to prevent — a session admitted for DRM serving + // while capture and input route down the X11 path. + if crate::platform::linux::display_server_forced() && crate::platform::linux::is_x11() { + return None; + } + let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); + if !is_gdm_user(&values[1]) + || get_display_server_of_session(&values[0]) != DISPLAY_SERVER_WAYLAND + { + return None; + } + // The cached tri-state, never the probing form: this runs on the unauthenticated login path, + // so it must not wait out a probe deadline. Only a definitive unavailable hands the seat to + // X11; an unsettled result keeps the maybe-live greeter (settling happens off-thread). + if crate::server::drm_capturer::availability_cached() + == crate::server::drm_capturer::Availability::Unavailable + { + return None; + } + Some(values[1].clone()) +} + +fn cached_username_from_state( + seat0_username: Option, + managed_session: Option<(&str, bool)>, +) -> String { + if let Some(username) = seat0_username { + return username; + } + match managed_session { + Some((username, true)) => username.to_owned(), + _ => String::new(), + } +} + +fn schedule_seat0_refresh(next_refresh: Option, now: Instant) -> (Option, bool) { + if next_refresh.is_some_and(|deadline| now < deadline) { + return (next_refresh, false); + } + (Some(now + SEAT0_REFRESH_INTERVAL), true) +} + +/// Returns the last settled username without running external commands. +pub fn get_cached_username() -> String { + let seat0_username = SEAT0_SNAPSHOT.lock().unwrap().username.clone().flatten(); + let username = { + let manager = DESKTOP_MANAGER.lock().unwrap(); + let Some(manager) = manager.as_ref() else { + return String::new(); + }; + cached_username_from_state( + seat0_username, + Some((&manager.child_username, manager.is_running())), + ) + }; + if username.is_empty() { + kick_seat0_refresh(); + } + username } pub fn get_username() -> String { + if DESKTOP_MANAGER.lock().unwrap().is_none() { + return "".to_owned(); + } + // Computed with the manager lock RELEASED: the lookup runs loginctl, and at a greeter the + // DRM probe, and holding DESKTOP_MANAGER across those waits serializes every caller behind + // one slow probe. + if let Some(seat0_username) = refresh_seat0_snapshot() { + return seat0_username; + } match &*DESKTOP_MANAGER.lock().unwrap() { Some(manager) => { - if let Some(seat0_username) = manager.get_supported_display_seat0_username() { - seat0_username + if manager.is_running() && !manager.child_username.is_empty() { + manager.child_username.clone() } else { - if manager.is_running() && !manager.child_username.is_empty() { - manager.child_username.clone() - } else { - "".to_owned() - } + "".to_owned() } } None => "".to_owned(), @@ -258,33 +438,13 @@ impl DesktopManager { } pub fn new() -> Self { - let mut seat0_username = "".to_owned(); - let mut seat0_display_server = "".to_owned(); - let seat0_values = get_values_of_seat0(&[0, 2]); - if !seat0_values[0].is_empty() { - seat0_username = seat0_values[1].clone(); - seat0_display_server = get_display_server_of_session(&seat0_values[0]); - } Self { - seat0_username, - seat0_display_server, child_username: "".to_owned(), child_exit: Arc::new(AtomicBool::new(true)), is_child_running: Arc::new(AtomicBool::new(false)), } } - fn get_supported_display_seat0_username(&self) -> Option { - if is_gdm_user(&self.seat0_username) && self.seat0_display_server == DISPLAY_SERVER_WAYLAND - { - None - } else if self.seat0_username.is_empty() { - None - } else { - Some(self.seat0_username.clone()) - } - } - #[inline] fn get_xauth() -> String { let xauth = get_env_var("XAUTHORITY"); @@ -1100,6 +1260,38 @@ fn pam_get_service_name() -> String { mod tests { use super::*; + #[test] + fn cached_username_prefers_seat0_and_running_managed_session() { + assert_eq!( + cached_username_from_state(Some("seat0".to_owned()), Some(("managed", true))), + "seat0" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", true))), + "managed" + ); + assert_eq!( + cached_username_from_state(None, Some(("managed", false))), + "" + ); + assert_eq!(cached_username_from_state(None, None), ""); + } + + #[test] + fn seat0_refresh_schedule_limits_process_wide_rate() { + let started = Instant::now(); + let (next_refresh, should_refresh) = schedule_seat0_refresh(None, started); + assert!(should_refresh); + + let (unchanged, should_refresh) = schedule_seat0_refresh(next_refresh, started); + assert!(!should_refresh); + assert_eq!(unchanged, next_refresh); + + let (_, should_refresh) = + schedule_seat0_refresh(next_refresh, started + SEAT0_REFRESH_INTERVAL); + assert!(should_refresh); + } + #[test] fn session_scope_truncates_at_first_scope() { assert_eq!( diff --git a/src/platform/macos.mm b/src/platform/macos.mm index 3303855a619..d4038da8e70 100644 --- a/src/platform/macos.mm +++ b/src/platform/macos.mm @@ -118,16 +118,18 @@ // https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42 extern "C" float BackingScaleFactor(uint32_t display) { - NSArray *screens = [NSScreen screens]; - for (NSScreen *screen in screens) { - NSDictionary *deviceDescription = [screen deviceDescription]; - NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; - CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; - if (screenDisplayID == display) { - return [screen backingScaleFactor]; + @autoreleasepool { + NSArray *screens = [NSScreen screens]; + for (NSScreen *screen in screens) { + NSDictionary *deviceDescription = [screen deviceDescription]; + NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; + CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; + if (screenDisplayID == display) { + return [screen backingScaleFactor]; + } } + return 1; } - return 1; } // https://github.com/jhford/screenresolution/blob/master/cg_utils.c diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 2e68cf5d824..4f85a4b0f4f 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String { s } +fn write_plist_atomically(path: &str, body: &str) -> ResultType<()> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let temporary = format!("{}.tmp.{}", path, std::process::id()); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.set_permissions(std::fs::Permissions::from_mode(0o644))?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + Ok::<(), std::io::Error>(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map_err(Into::into) +} + +pub fn write_plists() -> ResultType<()> { + let daemon_plist_path = format!( + "/Library/LaunchDaemons/com.carriez.{}_service.plist", + crate::get_app_name() + ); + let agent_plist_path = format!( + "/Library/LaunchAgents/com.carriez.{}_server.plist", + crate::get_app_name() + ); + let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { + bail!("daemon.plist not found in embedded resources"); + }; + let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read daemon.plist"); + }; + let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { + bail!("agent.plist not found in embedded resources"); + }; + let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read agent.plist"); + }; + write_plist_atomically(&daemon_plist_path, &daemon_plist_body)?; + write_plist_atomically(&agent_plist_path, &agent_plist_body)?; + log::info!("[write-plists] Wrote daemon and agent plists"); + Ok(()) +} + pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool { // to-do: do together with win/linux about refactory start/stop service if !is_installed_daemon(false) { @@ -659,6 +708,61 @@ pub fn get_active_userid() -> String { get_active_user("-n") } +/// Return every UID with a login-window/session entry. Fast user switching +/// can leave several GUI bootstrap domains alive at once, so updating only +/// the console user can leave another user's agent on the old bundle. +pub(crate) fn get_logged_in_uids() -> Vec { + let mut uids = std::collections::BTreeSet::new(); + if let Ok(output) = std::process::Command::new("/usr/bin/who").output() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(username) = line.split_whitespace().next() else { + continue; + }; + let Ok(output) = std::process::Command::new("/usr/bin/id") + .args(["-u", username]) + .output() + else { + continue; + }; + let Ok(uid) = String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + else { + continue; + }; + let gui_domain = format!("gui/{}", uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(uid); + } + } + } + if let Ok(active_uid) = get_active_userid().parse::() { + if active_uid == 0 { + // UID 0 owns /dev/console while the LoginWindow session is active. + // Query that server even when fast-switched GUI domains also exist. + uids.insert(0); + } else { + let gui_domain = format!("gui/{}", active_uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(active_uid); + } + } + } + if uids.is_empty() { + // The login window has no ordinary gui/0 bootstrap domain. + uids.insert(0); + } + uids.into_iter().collect() +} + pub fn get_active_user_home() -> Option { let username = get_active_username(); if !username.is_empty() { @@ -728,8 +832,12 @@ pub fn lock_screen() { .ok(); } +/// Starts the macOS system service IPC listener and the background +/// silent auto-update thread. pub fn start_os_service() { log::info!("Username: {}", crate::username()); + // Silent auto-update — runs as root via LaunchDaemon, no osascript dialog needed + crate::updater::start_auto_update_macos(); if let Err(err) = crate::ipc::start("_service") { log::error!("Failed to start ipc_service: {}", err); } @@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> { Ok(()) } +fn backup_update_plist(source: &str, backup: &str) -> ResultType<()> { + match std::fs::symlink_metadata(source) { + Ok(metadata) => { + if !metadata.file_type().is_file() { + bail!("[root-update] plist is not a regular file: {}", source); + } + std::fs::copy(source, backup)?; + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("[root-update] required installed plist is missing: {}", source) + } + Err(err) => Err(err.into()), + } +} + +fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultType<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + // Frameworks legitimately use internal symlinks (Resources, + // Versions/Current), but never allow a link to leave its framework. + let Some(framework_root) = framework_root else { + bail!("[root-update] symlink outside framework: {}", path.display()); + }; + let target = std::fs::read_link(path)?; + let target = if target.is_absolute() { + target + } else { + path.parent().unwrap_or(Path::new("/")).join(target) + }; + let target = std::fs::canonicalize(target)?; + let framework_root = std::fs::canonicalize(framework_root)?; + if target.starts_with(&framework_root) { + return Ok(()); + } + bail!("[root-update] symlink in update bundle: {}", path.display()); + } + if metadata.file_type().is_dir() { + for entry in std::fs::read_dir(path)? { + let child = entry?.path(); + let child_framework_root = if child + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".framework")) + { + Some(child.as_path()) + } else { + framework_root + }; + validate_update_tree(&child, child_framework_root)?; + } + } else if !metadata.file_type().is_file() { + bail!("[root-update] unsupported file in update bundle: {}", path.display()); + } + Ok(()) +} + +/// Performs a silent update from a DMG file without any osascript dialog. +/// Must be called from a process running as root (e.g. the service binary). +pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> { + let app_name = crate::get_app_name(); + if app_name.is_empty() + || !app_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + bail!("[root-update] unsafe application name"); + } + let app_bundle = format!("/Applications/{}.app", app_name); + let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp") + .args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"]) + .output()?; + let tmp_dir = String::from_utf8(tmp_dir_output.stdout) + .map_err(|e| anyhow!("[root-update] mktemp output error: {}", e))? + .trim() + .to_string(); + if tmp_dir.is_empty() { + bail!("[root-update] Failed to create temp directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))?; + } + let agent_plist = format!("/Library/LaunchAgents/com.carriez.{}_server.plist", app_name); + let daemon_plist = format!("/Library/LaunchDaemons/com.carriez.{}_service.plist", app_name); + + log::info!("[root-update] Starting silent root update from {}", dmg_path); + // Check sessions before extracting to avoid unnecessary work + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] Active session detected, deferring update."); + } + // Extract DMG to temp dir + extract_dmg_into_existing_dir(dmg_path, &tmp_dir)?; + let src_app = format!("{}/{}.app", tmp_dir, app_name); + log::info!("[root-update] DMG extracted to {}", tmp_dir); + validate_update_tree(Path::new(&src_app), None)?; + + // Bind the downloaded asset to the version returned by the update + // service before changing plists or executing anything from the staged + // bundle. A release asset with the right filename but the wrong bundle + // must not be allowed to replace the installed application. + let info_plist = format!("{}/Contents/Info.plist", src_app); + let staged_version_result = (|| -> ResultType { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleShortVersionString", &info_plist]) + .output()?; + if !output.status.success() { + bail!( + "[root-update] failed to read staged bundle version: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let version = String::from_utf8(output.stdout) + .map_err(|err| anyhow!("[root-update] staged bundle version is not UTF-8: {}", err))?; + if version.trim().is_empty() { + bail!("[root-update] staged bundle version is empty"); + } + Ok(version.trim().to_owned()) + })(); + let staged_version = match staged_version_result { + Ok(version) => version, + Err(err) => { + if let Err(cleanup_err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + cleanup_err + ); + } + return Err(err); + } + }; + if staged_version != expected_version { + if let Err(err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + err + ); + } + bail!( + "[root-update] staged bundle version mismatch: expected {:?}, found {:?}", + expected_version, + staged_version + ); + } + + // A leftover backup makes `mv app app.bak` nest the live bundle inside + // the old directory instead of creating a transaction backup. Never + // overwrite or guess at recovery state left by an earlier interrupted + // update; require an administrator to inspect it first. + let app_backup = format!("{}.bak", app_bundle); + let failed_bundle = format!("{}.failed-update", app_bundle); + for recovery_path in [&app_backup, &failed_bundle] { + match std::fs::symlink_metadata(recovery_path) { + Ok(_) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!( + "[root-update] stale application recovery path requires inspection: {}", + recovery_path + ); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(err.into()); + } + } + } + + // Backup current plists before overwriting — needed for restore on reload failure + let daemon_plist_bak = format!("{}/daemon_plist.bak", tmp_dir); + let agent_plist_bak = format!("{}/agent_plist.bak", tmp_dir); + // Backups are part of the update transaction. Do not allow the new + // service binary to overwrite either live plist unless both installed + // definitions have been captured successfully. + backup_update_plist(&daemon_plist, &daemon_plist_bak)?; + backup_update_plist(&agent_plist, &agent_plist_bak)?; + + // Ensure the staged release contains the service executable before we + // proceed. Plist generation itself is done in this already-root process; + // launching a freshly extracted service binary from /tmp is not required. + let new_service = format!("{}/Contents/MacOS/service", src_app); + if !std::path::Path::new(&new_service).is_file() { + bail!("[root-update] staged service binary is missing: {}", new_service); + } + // The new binary writes its own plist definitions after the bundle is + // moved into its final root-owned location. This avoids executing code + // directly from /tmp while ensuring the plist matches the new release. + + // Final session check after extraction — minimize race window + if !crate::updater::has_no_active_conns_ipc() { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!("[root-update] Active session detected after extraction, deferring update."); + } + + // Let the detached-script launch settle before taking the affected-user + // snapshot. The final IPC check then happens after the delay and as close + // as possible to stopping those exact launchd domains. + std::thread::sleep(std::time::Duration::from_secs(3)); + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + let logged_in_uids = get_logged_in_uids(); + // UIDs are parsed as integers before embedding in the root-run shell + // script, so they cannot alter its command structure. + let uid_list = logged_in_uids + .iter() + .map(u32::to_string) + .collect::>() + .join(" "); + + // Write a shell script that runs detached after this function returns. + // We cannot directly replace /Applications/RustDesk.app while it is running, + // so we spawn a script that waits, kills processes, copies, and restarts. + let daemon_label = format!("com.carriez.{}_service", app_name); + let agent_label = format!("com.carriez.{}_server", app_name); + let script_path = format!("{}/rustdesk_update.sh", tmp_dir); + let script = format!( + r#"#!/bin/sh +rollback_done=0 +bundle_swapped=0 +bootstrap_agent() {{ + agent_uid="$1" + if [ "$agent_uid" != "0" ]; then + launchctl bootstrap gui/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl bootstrap user/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + else + # At the login window there is no gui/0 domain. launchctl load uses + # the plist's LoginWindow/Aqua session policy instead. + launchctl load -w -S LoginWindow "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + fi +}} +bootstrap_agents() {{ + for agent_uid in {uid_list}; do + bootstrap_agent "$agent_uid" || return 1 + done +}} +loginwindow_asid() {{ + root_user_info=$(launchctl print user/0 2>/dev/null || true) + root_login_asid=$(printf '%s\n' "$root_user_info" | \ + awk '/^[[:space:]]*asid = [0-9]+[[:space:]]*$/ {{print $3; exit}}') + case "$root_login_asid" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$root_login_asid" +}} +bootout_agents() {{ + # Legacy launchctl commands can report success despite operational + # failure. Treat these as requests; stop_agents verifies the result. + stopping_loginwindow_asid="" + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + launchctl bootout gui/"$agent_uid"/{agent_label} 2>/dev/null || true + launchctl bootout user/"$agent_uid"/{agent_label} 2>/dev/null || true + else + # LoginWindow jobs run in a login/ domain even though + # legacy root `launchctl load` is issued from the system context. + # Remove every applicable registration before killing the process + # so KeepAlive cannot immediately respawn it. + launchctl unload -w -S LoginWindow "{agent_plist}" 2>/dev/null || true + stopping_loginwindow_asid=$(loginwindow_asid || true) + if [ -n "$stopping_loginwindow_asid" ]; then + launchctl bootout login/"$stopping_loginwindow_asid"/{agent_label} 2>/dev/null || true + fi + launchctl bootout user/0/{agent_label} 2>/dev/null || true + launchctl bootout system/{agent_label} 2>/dev/null || true + launchctl unload -w "{agent_plist}" 2>/dev/null || true + fi + done +}} +find_agent_pid() {{ + agent_uid="$1" + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null; then + printf '%s\n' "$candidate_pid" + return 0 + fi + done + return 1 +}} +launchd_agent_pid() {{ + agent_uid="$1" + agent_info=$(launchctl print gui/"$agent_uid"/{agent_label} 2>/dev/null || \ + launchctl print user/"$agent_uid"/{agent_label} 2>/dev/null || true) + agent_job_pid=$(printf '%s\n' "$agent_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$agent_job_pid" ] && \ + printf '%s\n' "$agent_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null; then + printf '%s\n' "$agent_job_pid" + return 0 + fi + return 1 +}} +agent_pid_for_uid() {{ + agent_uid="$1" + if [ "$agent_uid" = "0" ]; then + # LoginWindow agents have no ordinary gui/0 bootstrap domain. Locate + # the root-owned --server process and validate it below instead. + find_agent_pid "$agent_uid" + else + launchd_agent_pid "$agent_uid" + fi +}} +agent_process_matches() {{ + agent_uid="$1" + agent_pid="$2" + process_uid=$(ps -p "$agent_pid" -o uid= 2>/dev/null | tr -d '[:space:]') + process_args=$(ps -p "$agent_pid" -o args= 2>/dev/null || true) + [ "$process_uid" = "$agent_uid" ] && \ + printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null +}} +capture_stopping_agent_pids() {{ + stopping_agent_pids="" + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + stopping_agent_pids="$stopping_agent_pids $candidate_pid" + fi + done + done +}} +terminate_agent_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +terminate_user_bundle_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +user_bundle_processes_absent() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + return 1 + fi + done + done + return 0 +}} +stop_user_bundle_processes() {{ + terminate_user_bundle_processes + for _ in $(/usr/bin/seq 1 30); do + if user_bundle_processes_absent; then + sleep 2 + user_bundle_processes_absent && return 0 + fi + terminate_user_bundle_processes + sleep 1 + done + return 1 +}} +agent_jobs_absent() {{ + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + if launchctl print gui/"$agent_uid"/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/"$agent_uid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + else + if launchctl print system/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/0/{agent_label} >/dev/null 2>&1; then + return 1 + fi + if [ -n "$stopping_loginwindow_asid" ] && \ + launchctl print login/"$stopping_loginwindow_asid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + fi + find_agent_pid "$agent_uid" >/dev/null 2>&1 && return 1 + done + return 0 +}} +captured_agent_pids_gone() {{ + for stopped_pid in $stopping_agent_pids; do + kill -0 "$stopped_pid" 2>/dev/null && return 1 + done + return 0 +}} +agents_stopped() {{ + captured_agent_pids_gone && agent_jobs_absent +}} +stop_agents() {{ + bootout_agents + terminate_agent_processes + for _ in $(/usr/bin/seq 1 30); do + if agents_stopped; then + sleep 2 + agents_stopped && return 0 + fi + terminate_agent_processes + sleep 1 + done + return 1 +}} +capture_agent_snapshot() {{ + agent_pids="" + for agent_uid in {uid_list}; do + agent_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$agent_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$agent_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$agent_pid" || return 1 + agent_pids="$agent_pids $agent_uid:$agent_pid" + done + return 0 +}} +agent_snapshot_stable() {{ + for agent_entry in $agent_pids; do + agent_uid=$(printf '%s\n' "$agent_entry" | cut -d: -f1) + expected_pid=$(printf '%s\n' "$agent_entry" | cut -d: -f2) + current_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$current_pid" ] && [ "$current_pid" = "$expected_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$current_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$current_pid" || return 1 + done + return 0 +}} +agent_ready() {{ + for _ in $(/usr/bin/seq 1 30); do + if capture_agent_snapshot; then + sleep 2 + agent_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +daemon_snapshot_stable() {{ + stable_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stable_daemon_pid=$(printf '%s\n' "$stable_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + [ -n "$daemon_pid" ] && \ + [ "$stable_daemon_pid" = "$daemon_pid" ] && \ + printf '%s\n' "$stable_daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null +}} +daemon_ready() {{ + daemon_pid="" + for _ in $(/usr/bin/seq 1 30); do + daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + daemon_pid=$(printf '%s\n' "$daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$daemon_pid" ] && \ + printf '%s\n' "$daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null; then + sleep 2 + daemon_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +capture_stopping_daemon_pid() {{ + stopping_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stopping_daemon_pid=$(printf '%s\n' "$stopping_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') +}} +daemon_stopped() {{ + if [ -n "$stopping_daemon_pid" ] && kill -0 "$stopping_daemon_pid" 2>/dev/null; then + return 1 + fi + ! launchctl print system/{daemon_label} >/dev/null 2>&1 +}} +stop_daemon() {{ + capture_stopping_daemon_pid + # Command status is advisory. daemon_stopped verifies that both the + # captured process generation and launchd registration are gone. + launchctl bootout system/{daemon_label} 2>/dev/null || \ + launchctl unload -w "{daemon_plist}" 2>/dev/null || true + for _ in $(/usr/bin/seq 1 30); do + if daemon_stopped; then + sleep 2 + daemon_stopped && return 0 + fi + sleep 1 + done + return 1 +}} +write_new_plists() {{ + /Applications/{app_name}.app/Contents/MacOS/service --write-plists \ + >"{tmp_dir}/write-plists.log" 2>&1 & + write_pid=$! + for _ in $(/usr/bin/seq 1 60); do + if ! kill -0 "$write_pid" 2>/dev/null; then + wait "$write_pid" + return $? + fi + sleep 1 + done + kill -TERM "$write_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$write_pid" 2>/dev/null || true + wait "$write_pid" 2>/dev/null || true + return 124 +}} +restore_old_bundle() {{ + [ "$bundle_swapped" -eq 1 ] || return 0 + if [ ! -d "{app_bundle}.bak" ] || [ -L "{app_bundle}.bak" ]; then + echo "[root-update] CRITICAL: valid application backup is unavailable" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + if [ -e "{app_bundle}" ] || [ -L "{app_bundle}" ]; then + if [ -e "{app_bundle}.failed-update" ] || [ -L "{app_bundle}.failed-update" ] || \ + ! mv "{app_bundle}" "{app_bundle}.failed-update"; then + echo "[root-update] CRITICAL: could not vacate failed bundle safely" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + fi + if ! mv "{app_bundle}.bak" "{app_bundle}"; then + echo "[root-update] CRITICAL: failed to restore application bundle" >> {tmp_dir}/rustdesk_root_update.log + if [ ! -e "{app_bundle}" ] && [ ! -L "{app_bundle}" ]; then + mv "{app_bundle}.failed-update" "{app_bundle}" 2>/dev/null || true + fi + return 1 + fi + rm -rf "{app_bundle}.failed-update" 2>/dev/null || true + bundle_swapped=0 + return 0 +}} +rollback_transaction() {{ + # Rollback restores and verifies unattended service state. It does not + # guarantee relaunching GUI windows that were stopped by the transaction. + [ "$rollback_done" -eq 0 ] || return 0 + rollback_done=1 + restore_failed=0 + stop_daemon || restore_failed=1 + capture_stopping_agent_pids + stop_agents || restore_failed=1 + restore_old_bundle || restore_failed=1 + cp "{daemon_plist_bak}" "{daemon_plist}" || restore_failed=1 + cp "{agent_plist_bak}" "{agent_plist}" || restore_failed=1 + touch /var/root/.rustdeskupdate_failed || restore_failed=1 + if ! launchctl load -w "{daemon_plist}" 2>/dev/null && \ + ! launchctl bootstrap system "{daemon_plist}" 2>/dev/null; then + restore_failed=1 + fi + daemon_ready || restore_failed=1 + bootstrap_agents || restore_failed=1 + agent_ready || restore_failed=1 + if [ "$restore_failed" -eq 0 ] && \ + {{ ! daemon_snapshot_stable || ! agent_snapshot_stable; }}; then + restore_failed=1 + fi + if [ "$restore_failed" -ne 0 ]; then + echo "[root-update] CRITICAL: rollback restoration failed" >> {tmp_dir}/rustdesk_root_update.log + else + echo "[root-update] Rollback daemon and agents verified healthy" >> {tmp_dir}/rustdesk_root_update.log + fi +}} +trap rollback_transaction EXIT +gui_uids="" +for agent_uid in {uid_list}; do + for pid in $(pgrep -u "$agent_uid" -x {app_name} || true); do + process_args=$(ps -p "$pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null && \ + ! printf '%s\n' "$process_args" | grep -E "(^|[[:space:]])(--server|--service|--update)([[:space:]]|$)" >/dev/null; then + gui_uids="$gui_uids $agent_uid" + break + fi + done +done +if ! capture_agent_snapshot; then + echo "[root-update] old LaunchAgent readiness check failed before shutdown" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +capture_stopping_agent_pids +if ! stop_daemon; then + echo "[root-update] daemon did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! stop_agents; then + echo "[root-update] old LaunchAgent did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Agents have already been verified absent. Stop and verify any remaining GUI +# processes as well so no process keeps the old bundle mapped across the swap. +if ! stop_user_bundle_processes; then + echo "[root-update] RustDesk GUI process did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +staged_bundle="{tmp_dir}/staged.app" +if [ -e "$staged_bundle" ] || [ -L "$staged_bundle" ]; then + echo "[root-update] staged bundle path already exists, aborting" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! ditto {src_app} "$staged_bundle" 2>/dev/null; then + echo "[root-update] ditto failed, aborting update" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +# Validate staged bundle before atomic swap +if [ ! -d "$staged_bundle/Contents/MacOS" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/{app_name}" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/service" ] || \ + [ ! -f "$staged_bundle/Contents/Info.plist" ]; then + echo "[root-update] staged bundle validation failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +if ! mv {app_bundle} {app_bundle}.bak; then + echo "[root-update] backup mv failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +bundle_swapped=1 +if ! mv "$staged_bundle" {app_bundle}; then + echo "[root-update] replacement mv failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Install the entire bundle as root-owned. The LaunchDaemon executes code +# from this bundle, so no nested framework, helper, or resource may remain +# user-writable. +if ! chown -R root:wheel {app_bundle} || ! chmod -R go-w {app_bundle}; then + echo "[root-update] chown failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +xattr -r -d com.apple.quarantine {app_bundle} || true +# Keep root-executed files AND entire ancestor chain root-owned — prevent privilege escalation +if ! chown root:wheel {app_bundle} || \ + ! chmod 755 {app_bundle} || \ + ! chown root:wheel {app_bundle}/Contents || \ + ! chmod 755 {app_bundle}/Contents || \ + ! chown root:wheel {app_bundle}/Contents/MacOS || \ + ! chmod 755 {app_bundle}/Contents/MacOS || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/service || \ + ! chmod 755 {app_bundle}/Contents/MacOS/service || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/{app_name} || \ + ! chmod 755 {app_bundle}/Contents/MacOS/{app_name}; then + echo "[root-update] hardening failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Generate launchd definitions from the new, final-location binary. The +# subprocess is bounded and its output is retained for diagnosis; failure +# causes the existing bundle/plists to be restored by the EXIT trap. +if ! write_new_plists; then + echo "[root-update] CRITICAL: new binary failed to write plists" >> {tmp_dir}/rustdesk_root_update.log + cat "{tmp_dir}/write-plists.log" >> {tmp_dir}/rustdesk_root_update.log 2>/dev/null || true + exit 1 +fi +echo "[root-update] Plist definitions written by new binary" >> {tmp_dir}/rustdesk_root_update.log +# Check daemon registration and readiness BEFORE removing backup. launchctl +# load/bootstrap only registers the job; the service can still exit immediately. +if ! launchctl load -w {daemon_plist} 2>/dev/null && \ + ! launchctl bootstrap system {daemon_plist} 2>/dev/null; then + echo "[root-update] CRITICAL: daemon reload failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! daemon_ready; then + echo "[root-update] CRITICAL: daemon failed readiness check, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Bootstrap agent BEFORE removing backup — needed for rollback on failure. +# This also uses launchctl load for the login-window/no-console-user case. +if ! bootstrap_agents || ! agent_ready; then + echo "[root-update] CRITICAL: agent bootstrap failed, rolling back" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Recheck daemon liveness after the agent is restored and immediately before +# deleting the only rollback bundle. +if ! daemon_snapshot_stable || ! agent_snapshot_stable; then + echo "[root-update] CRITICAL: daemon or agent stopped before commit, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Only remove backup after BOTH daemon AND agent confirmed running +rollback_done=1 +bundle_swapped=0 +if ! rm -rf "{app_bundle}.bak"; then + echo "[root-update] WARNING: committed update but could not remove backup" >> {tmp_dir}/rustdesk_root_update.log +fi +for gui_uid in $gui_uids; do + launchctl asuser "$gui_uid" open -a "{app_bundle}" || true +done +echo "[root-update] Done!" >> {tmp_dir}/rustdesk_root_update.log +rm -rf {tmp_dir} +"#, + app_name = app_name, + app_bundle = app_bundle, + src_app = src_app, + uid_list = uid_list, + daemon_plist = daemon_plist, + agent_plist = agent_plist, + tmp_dir = tmp_dir, + daemon_label = daemon_label, + agent_label = agent_label, + daemon_plist_bak = daemon_plist_bak, + agent_plist_bak = agent_plist_bak, + ); + + { + use std::io::Write; + if let Err(err) = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&script_path) + .and_then(|mut f| f.write_all(script.as_bytes())) + { + return Err(err.into()); + } + } + match Command::new("/bin/chmod") + .args(&["+x", &script_path]) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => { + bail!( + "[root-update] failed to make update script executable: {}", + status + ); + } + Err(err) => { + return Err(err.into()); + } + } + // Reject session changes observed before launch, but this snapshot is + // best-effort: it is not atomic with shutdown in the detached script. + if get_logged_in_uids() != logged_in_uids { + bail!("[root-update] GUI session set changed before update launch"); + } + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + if let Err(err) = Command::new("/bin/bash") + .arg(&script_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + { + return Err(err.into()); + } + + log::info!("[root-update] Update script launched."); + Ok(()) +} + pub fn extract_update_dmg(file: &str) { let update_temp_dir = get_update_temp_dir_string(); let mut evt: HashMap<&str, String> = @@ -931,37 +1793,63 @@ pub fn extract_update_dmg(file: &str) { } fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_point = "/Volumes/RustDeskUpdate"; let target_path = Path::new(target_dir); - if target_path.exists() { std::fs::remove_dir_all(target_path)?; } std::fs::create_dir_all(target_path)?; + extract_dmg_inner(dmg_path, target_dir) +} - let status = Command::new("hdiutil") - .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) +fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + if !target_path.exists() { + bail!("[root-update] Temp directory does not exist: {:?}", target_path); + } + extract_dmg_inner(dmg_path, target_dir) +} + +fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let mount_output = Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskmount-XXXXXX"]) + .output()?; + if !mount_output.status.success() { + bail!("Failed to create a private DMG mount directory"); + } + let mount_point = String::from_utf8(mount_output.stdout) + .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? + .trim() + .to_owned(); + if mount_point.is_empty() { + bail!("Failed to create a private DMG mount directory"); + } + let status = Command::new("/usr/bin/hdiutil") + .args(["attach", "-nobrowse", "-mountpoint"]) + .arg(&mount_point) + .arg(dmg_path) .status()?; if !status.success() { + let _ = std::fs::remove_dir(&mount_point); bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); } - struct DmgGuard(&'static str); + struct DmgGuard(String); impl Drop for DmgGuard { fn drop(&mut self) { - let _ = Command::new("hdiutil") - .args(&["detach", self.0, "-force"]) + let _ = Command::new("/usr/bin/hdiutil") + .args(["detach", self.0.as_str(), "-force"]) .status(); + let _ = std::fs::remove_dir(&self.0); } } - let _guard = DmgGuard(mount_point); + let _guard = DmgGuard(mount_point.clone()); let app_name = format!("{}.app", crate::get_app_name()); let src_path = format!("{}/{}", mount_point, app_name); let dest_path = format!("{}/{}", target_dir, app_name); - let copy_status = Command::new("ditto") + let copy_status = Command::new("/usr/bin/ditto") .args(&[&src_path, &dest_path]) .status()?; diff --git a/src/platform/privileges_scripts/daemon.plist b/src/platform/privileges_scripts/daemon.plist index c003ea2bee0..dbd1aa70f56 100644 --- a/src/platform/privileges_scripts/daemon.plist +++ b/src/platform/privileges_scripts/daemon.plist @@ -23,8 +23,8 @@ WorkingDirectory /Applications/RustDesk.app/Contents/MacOS/ StandardErrorPath - /tmp/rustdesk_service.err + /var/log/rustdesk_service.err StandardOutPath - /tmp/rustdesk_service.out + /var/log/rustdesk_service.out diff --git a/src/platform/privileges_scripts/install.scpt b/src/platform/privileges_scripts/install.scpt index 797d02c9e24..acf86ab93af 100644 --- a/src/platform/privileges_scripts/install.scpt +++ b/src/platform/privileges_scripts/install.scpt @@ -1,14 +1,18 @@ on run {daemon_file, agent_file, user} + set prefs_dir to "/Users/" & user & "/Library/Preferences/com.carriez.RustDesk/" + set prefs_toml to quoted form of (prefs_dir & "RustDesk.toml") + set prefs2_toml to quoted form of (prefs_dir & "RustDesk2.toml") + set sh1 to "echo " & quoted form of daemon_file & " > /Library/LaunchDaemons/com.carriez.RustDesk_service.plist && chown root:wheel /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh2 to "echo " & quoted form of agent_file & " > /Library/LaunchAgents/com.carriez.RustDesk_server.plist && chown root:wheel /Library/LaunchAgents/com.carriez.RustDesk_server.plist;" - set sh3 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh3 to "cp -rf " & prefs_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh4 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk2.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh4 to "cp -rf " & prefs2_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh5 to "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" + set sh5 to "launchctl bootout system/com.carriez.RustDesk_service 2>/dev/null || launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || true; launchctl bootstrap system /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh to sh1 & sh2 & sh3 & sh4 & sh5 diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5ced84e3893..5253895ddc6 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -98,12 +98,19 @@ use windows_service::{ use winreg::{enums::*, RegKey}; mod acl; +mod installer_handoff; +mod installer_shell; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ set_path_permission, set_path_permission_for_portable_service_shmem_dir, set_path_permission_for_portable_service_shmem_file, validate_path_for_portable_service_shmem_dir, }; +use installer_handoff::run_cmds; +use installer_shell::{ + embedded_shortcut_commands, embedded_tray_shortcut_commands, escape_nested_cmd_ampersands, + shortcut_bytes, validate_install_value, +}; pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; @@ -113,6 +120,17 @@ const REG_NAME_INSTALL_DESKTOPSHORTCUTS: &str = "DESKTOPSHORTCUTS"; const REG_NAME_INSTALL_STARTMENUSHORTCUTS: &str = "STARTMENUSHORTCUTS"; pub const REG_NAME_INSTALL_PRINTER: &str = "PRINTER"; +fn validate_install_app_name(app_name: &str) -> ResultType<()> { + if app_name.is_empty() + || !app_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + bail!("Application name must match [a-zA-Z0-9-]+"); + } + Ok(()) +} + pub fn get_focused_display(displays: Vec) -> Option { unsafe { let hwnd = GetForegroundWindow(); @@ -1499,6 +1517,7 @@ fn get_after_install( ) -> String { let app_name = crate::get_app_name(); let ext = app_name.to_lowercase(); + let nested_exe = escape_nested_cmd_ampersands(exe); // reg delete HKEY_CURRENT_USER\Software\Classes for // https://github.com/rustdesk/rustdesk/commit/f4bdfb6936ae4804fc8ab1cf560db192622ad01a @@ -1534,17 +1553,17 @@ fn get_after_install( {start_menu_shortcuts} {reg_printer} reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f - reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f /ve /t REG_SZ /d \"\\\"{exe}\\\",0\" + reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\",0\" reg add HKEY_CLASSES_ROOT\\.{ext}\\shell /f reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open /f reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f - reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{exe}\\\" --play \\\"%%1\\\"\" + reg add HKEY_CLASSES_ROOT\\.{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\" --play \\\"%%1\\\"\" reg add HKEY_CLASSES_ROOT\\{ext} /f reg add HKEY_CLASSES_ROOT\\{ext} /f /v \"URL Protocol\" /t REG_SZ /d \"\" reg add HKEY_CLASSES_ROOT\\{ext}\\shell /f reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open /f reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f - reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{exe}\\\" \\\"%%1\\\"\" + reg add HKEY_CLASSES_ROOT\\{ext}\\shell\\open\\command /f /ve /t REG_SZ /d \"\\\"{nested_exe}\\\" \\\"%%1\\\"\" netsh advfirewall firewall add rule name=\"{app_name} Service\" dir=out action=allow program=\"{exe}\" enable=yes netsh advfirewall firewall add rule name=\"{app_name} Service\" dir=in action=allow program=\"{exe}\" enable=yes {create_service} @@ -1578,48 +1597,38 @@ pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> Res let app_name = crate::get_app_name(); let current_exe = std::env::current_exe()?; - - let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); - let cur_exe = current_exe.to_str().unwrap_or("").to_owned(); - let shortcut_icon_location = get_shortcut_icon_location(&path, &cur_exe); - let mk_shortcut = write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\{app_name}.lnk\" - -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - {shortcut_icon_location} -oLink.Save - " - ), - "vbs", + let cur_exe = current_exe + .to_str() + .ok_or_else(|| anyhow!("Current executable path is not valid Unicode"))? + .to_owned(); + for value in [&path, &exe, &cur_exe] { + validate_install_value(value)?; + } + let config_path = Config::file(); + validate_install_value( + config_path + .to_str() + .ok_or_else(|| anyhow!("Configuration path is not valid Unicode"))?, + )?; + let shortcut_icon_location = get_custom_icon(&path, &cur_exe); + if let Some(icon) = shortcut_icon_location.as_deref() { + validate_install_value(icon)?; + } + // The elevated runner expands this to `%~f0.dir`, beside its protected copy. + // Do not stage privileged shortcut artifacts in the user-writable `%TEMP%`. + let tmp_path = "%RUSTDESK_OUTPUT_DIR%".to_owned(); + let mk_shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(&exe, None, shortcut_icon_location.as_deref())?, + &format!("{app_name}.lnk"), "mk_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned(); - // https://superuser.com/questions/392061/how-to-make-a-shortcut-from-cmd - let uninstall_shortcut = write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\Uninstall {app_name}.lnk\" -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - oLink.Arguments = \"--uninstall\" - oLink.IconLocation = \"msiexec.exe\" -oLink.Save - " - ), - "vbs", + ); + let uninstall_shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(&exe, Some("--uninstall"), Some("msiexec.exe"))?, + &format!("Uninstall {app_name}.lnk"), "uninstall_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned(); - let tray_shortcut = get_tray_shortcut(&path, &exe, &cur_exe, &tmp_path)?; + ); + let tray_shortcut_commands = + embedded_tray_shortcut_commands(&app_name, &exe, shortcut_icon_location.as_deref())?; let mut reg_value_desktop_shortcuts = "0".to_owned(); let mut reg_value_start_menu_shortcuts = "0".to_owned(); let mut reg_value_printer = "0".to_owned(); @@ -1660,15 +1669,12 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{start_menu}\\\" // Note: without if exist, the bat may exit in advance on some Windows7 https://github.com/rustdesk/rustdesk/issues/895 let dels = format!( " -if exist \"{mk_shortcut}\" del /f /q \"{mk_shortcut}\" -if exist \"{uninstall_shortcut}\" del /f /q \"{uninstall_shortcut}\" -if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" if exist \"{tmp_path}\\{app_name}.lnk\" del /f /q \"{tmp_path}\\{app_name}.lnk\" if exist \"{tmp_path}\\Uninstall {app_name}.lnk\" del /f /q \"{tmp_path}\\Uninstall {app_name}.lnk\" if exist \"{tmp_path}\\{app_name} Tray.lnk\" del /f /q \"{tmp_path}\\{app_name} Tray.lnk\" " ); - let src_exe = std::env::current_exe()?.to_str().unwrap_or("").to_string(); + let src_exe = cur_exe.clone(); // potential bug here: if run_cmd cancelled, but config file is changed. if let Some(lic) = get_license() { @@ -1681,7 +1687,7 @@ if exist \"{tmp_path}\\{app_name} Tray.lnk\" del /f /q \"{tmp_path}\\{app_name} "".to_owned() } else { format!(" -cscript \"{tray_shortcut}\" +{tray_shortcut_commands} copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" ") }; @@ -1716,11 +1722,11 @@ reg add {subkey} /f /v Publisher /t REG_SZ /d \"{app_name}\" reg add {subkey} /f /v VersionMajor /t REG_DWORD /d {version_major} reg add {subkey} /f /v VersionMinor /t REG_DWORD /d {version_minor} reg add {subkey} /f /v VersionBuild /t REG_DWORD /d {version_build} -reg add {subkey} /f /v UninstallString /t REG_SZ /d \"\\\"{exe}\\\" --uninstall\" +reg add {subkey} /f /v UninstallString /t REG_SZ /d \"\\\"{nested_exe}\\\" --uninstall\" reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} reg add {subkey} /f /v WindowsInstaller /t REG_DWORD /d 0 -cscript \"{mk_shortcut}\" -cscript \"{uninstall_shortcut}\" +{mk_shortcut_commands} +{uninstall_shortcut_commands} {tray_shortcuts} {shortcuts} copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" @@ -1730,7 +1736,8 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" {install_remote_printer} {sleep} ", - display_icon = get_custom_icon(&path, &cur_exe).unwrap_or(exe.to_string()), + display_icon = shortcut_icon_location.as_deref().unwrap_or(exe.as_str()), + nested_exe = escape_nested_cmd_ampersands(&exe), version = crate::VERSION.replace("-", "."), build_date = crate::BUILD_DATE, after_install = get_after_install( @@ -1836,10 +1843,9 @@ pub fn uninstall_me(kill_self: bool) -> ResultType<()> { run_cmds(get_uninstall(kill_self, true), true, "uninstall") } -fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType { - let mut cmds = cmds; +fn write_vbs(cmds: String, tip: &str) -> ResultType { + const UTF16LE_BOM: &[u8] = &[0xFF, 0xFE]; let mut tmp = std::env::temp_dir(); - // When dir contains these characters, the bat file will not execute in elevated mode. if vec!["&", "@", "^"] .drain(..) .any(|s| tmp.to_string_lossy().to_string().contains(s)) @@ -1848,31 +1854,14 @@ fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType = cmds.encode_utf16().collect(); - // utf8 -> utf16le which vbs support it only - file.write_all(to_le(&mut v))?; - } else { - file.write_all(cmds.as_bytes())?; - } + tmp.push(format!("{}_{}.vbs", crate::get_app_name(), tip)); + let mut file = fs::File::create(&tmp)?; + let cmds = cmds.replace("\r\n", "\n").replace('\n', "\r\n"); + let mut utf16: Vec = cmds.encode_utf16().collect(); + file.write_all(UTF16LE_BOM)?; + file.write_all(to_le(&mut utf16))?; file.sync_all()?; - return Ok(tmp); + Ok(tmp) } fn to_le(v: &mut [u16]) -> &[u8] { @@ -1882,37 +1871,6 @@ fn to_le(v: &mut [u16]) -> &[u8] { unsafe { v.align_to().1 } } -fn get_undone_file(tmp: &Path) -> ResultType { - Ok(tmp.with_file_name(format!( - "{}.undone", - tmp.file_name() - .ok_or(anyhow!("Failed to get filename of {:?}", tmp))? - .to_string_lossy() - ))) -} - -fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { - let tmp = write_cmds(cmds, "bat", tip)?; - let tmp2 = get_undone_file(&tmp)?; - let tmp_fn = tmp.to_str().unwrap_or(""); - // https://github.com/rustdesk/rustdesk/issues/6786#issuecomment-1879655410 - // Specify cmd.exe explicitly to avoid the replacement of cmd commands. - let res = runas::Command::new("cmd.exe") - .args(&["/C", &tmp_fn]) - .show(show) - .force_prompt(true) - .status(); - if !show { - allow_err!(std::fs::remove_file(tmp)); - } - let _ = res?; - if tmp2.exists() { - allow_err!(std::fs::remove_file(tmp2)); - bail!("{} failed", tip); - } - Ok(()) -} - pub fn toggle_blank_screen(v: bool) { let v = if v { TRUE } else { FALSE }; unsafe { @@ -2278,13 +2236,17 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String { } pub fn create_shortcut(id: &str) -> ResultType<()> { + if !crate::common::is_valid_untrusted_peer_id(id) { + bail!("Invalid peer id for shortcut"); + } + let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); // https://github.com/rustdesk/rustdesk/issues/13735 // Replace ':' with '_' for filename since ':' is not allowed in Windows filenames // https://github.com/rustdesk/hbb_common/blob/8b0e25867375ba9e6bff548acf44fe6d6ffa7c0e/src/config.rs#L1384 let filename = id.replace(':', "_"); let shortcut_icon_location = get_shortcut_icon_location("", &exe); - let shortcut = write_cmds( + let shortcut = write_vbs( format!( " Set oWS = WScript.CreateObject(\"WScript.Shell\") @@ -2298,7 +2260,6 @@ Set oLink = oWS.CreateShortcut(sLinkFile) oLink.Save " ), - "vbs", "connect_shortcut", )? .to_str() @@ -3158,6 +3119,64 @@ impl Drop for WakeLock { } } +// `check_process("--tray", ..)` can miss a tray process that is already running, +// and every miss spawns one more tray icon. +// +// The case confirmed in #15689: `run_after_run_cmds()` spawns the tray in the +// caller's own context, so installing or toggling the service from a RustDesk +// that was itself started elevated leaves a high integrity tray behind. A main +// window started normally afterwards runs at medium integrity and cannot open +// that process with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`. sysinfo then +// falls back to `PROCESS_QUERY_LIMITED_INFORMATION`, which is not enough for +// `GetModuleFileNameExW`, so the executable path comes back empty and the tray +// is skipped before its command line is ever looked at. +// +// A second blind spot: 32-bit builds read the command line through `wmic` +// (#11638), which is no longer installed by default since Windows 11 24H2. +// +// Both are cases of one process failing to inspect another, and patching the +// inspection has regressed twice already (#6692), so use a named mutex instead: +// the kernel answers without us needing any access to the other process. +// +// Returns `false` if another tray process is already running in this session. +pub fn try_lock_tray_single_instance() -> bool { + use winapi::um::{ + errhandlingapi::{GetLastError, SetLastError}, + synchapi::CreateMutexW, + }; + // `Local\` is the per session namespace, so the name is scoped to this + // session already and cannot be squatted by another user. + let name = wide_string(&format!("Local\\{}_tray", crate::get_app_name())); + unsafe { + // A successful `CreateMutexW` doesn't clear the last error, clear it to + // reliably detect `ERROR_ALREADY_EXISTS`. + SetLastError(0); + // The handle is deliberately kept open for the lifetime of the process. + let handle = CreateMutexW(null_mut(), FALSE, name.as_ptr()); + let last_error = GetLastError(); + if !handle.is_null() { + if last_error == ERROR_ALREADY_EXISTS { + CloseHandle(handle); + return false; + } + return true; + } + if last_error == ERROR_ACCESS_DENIED { + // The mutex exists but was created by a tray running at a higher + // integrity level, which is exactly the elevated tray described + // above. Defer to it instead of adding a second icon. + return false; + } + // Unexpected: show the tray icon anyway, a duplicated icon is better + // than never showing the tray icon at all. + log::warn!( + "Failed to create the tray single instance mutex: {}", + io::Error::from_raw_os_error(last_error as _) + ); + true + } +} + pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { log::info!("Uninstalling service..."); let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); @@ -3183,29 +3202,52 @@ pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { std::process::exit(0); } -pub fn install_service() -> bool { - log::info!("Installing service..."); - let _installing = crate::platform::InstallingService::new(); - let (_, path, _, exe) = get_install_info(); - let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); - let tray_shortcut = get_tray_shortcut(&path, &exe, &exe, &tmp_path).unwrap_or_default(); +fn get_install_service_commands(path: &str, exe: &str) -> ResultType { + let app_name = crate::get_app_name(); + for value in [path, exe] { + validate_install_value(value)?; + } + let config_path = Config::file(); + validate_install_value( + config_path + .to_str() + .ok_or_else(|| anyhow!("Configuration path is not valid Unicode"))?, + )?; + let shortcut_icon_location = get_custom_icon(path, exe); + if let Some(icon) = shortcut_icon_location.as_deref() { + validate_install_value(icon)?; + } + let tray_shortcut_commands = + embedded_tray_shortcut_commands(&app_name, exe, shortcut_icon_location.as_deref())?; let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); - Config::set_option("stop-service".into(), "".into()); - crate::ipc::EXIT_RECV_CLOSE.store(false, Ordering::Relaxed); - let cmds = format!( + Ok(format!( " chcp 65001 taskkill /F /IM {app_name}.exe{filter} -cscript \"{tray_shortcut}\" -copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" +{tray_shortcut_commands} +copy /Y \"%RUSTDESK_OUTPUT_DIR%\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\\" {import_config} {create_service} -if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" ", - app_name = crate::get_app_name(), - import_config = get_import_config(&exe), - create_service = get_create_service(&exe), - ); + import_config = get_import_config(exe), + create_service = get_create_service(exe), + )) +} + +pub fn install_service() -> bool { + log::info!("Installing service..."); + let _installing = crate::platform::InstallingService::new(); + let (_, path, _, exe) = get_install_info(); + Config::set_option("stop-service".into(), "".into()); + let cmds = match get_install_service_commands(&path, &exe) { + Ok(cmds) => cmds, + Err(err) => { + Config::set_option("stop-service".into(), "Y".into()); + log::error!("Failed to prepare service installation: {err}"); + return true; + } + }; + crate::ipc::EXIT_RECV_CLOSE.store(false, Ordering::Relaxed); if let Err(err) = run_cmds(cmds, false, "install") { Config::set_option("stop-service".into(), "Y".into()); crate::ipc::EXIT_RECV_CLOSE.store(true, Ordering::Relaxed); @@ -3264,6 +3306,18 @@ pub fn update_me(debug: bool) -> ResultType<()> { } let app_exe_name = &format!("{}.exe", &app_name); + // NOTE: The pids below are matched by command line, which can silently come + // back empty even while the processes are running: + // - a 32-bit build cannot read the command line of a 64-bit process, so it + // shells out to `wmic` instead (#11638), and `wmic` is no longer installed + // by default since Windows 11 24H2; + // - a non-elevated process cannot read the command line of an elevated one. + // The `taskkill` in the commands below matches by image name and is not + // affected, but `*_sessions` are then empty, so `_restore_session_guard` + // silently restores nothing and the update leaves the user without a tray + // icon and main window until the app is launched again. Reading the command + // line through `NtQueryInformationProcess` instead would fix the queries for + // every caller. let main_window_pids = crate::platform::get_pids_of_process_with_args::<_, &str>(&app_exe_name, &[]); let main_window_sessions = main_window_pids @@ -3647,47 +3701,20 @@ pub fn update_to(file: &str) -> ResultType<()> { // `1` and `3` must be done in custom actions. // We need also to handle the command line parsing to find the tray processes. pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { - let cmds = format!( - "chcp 65001 && msiexec /i {msi} {}", - if quiet { "/qn LAUNCH_TRAY_APP=N" } else { "" } - ); + let quiet_args = if quiet { " /qn LAUNCH_TRAY_APP=N" } else { "" }; + let cmds = + format!("chcp 65001 && msiexec /i \"{msi}\"{quiet_args} REBOOT=ReallySuppress /norestart"); run_cmds(cmds, false, "update-msi")?; Ok(()) } -pub fn get_tray_shortcut( - install_dir: &str, - exe: &str, - icon_source_exe: &str, - tmp_path: &str, -) -> ResultType { - let shortcut_icon_location = get_shortcut_icon_location(install_dir, icon_source_exe); - Ok(write_cmds( - format!( - " -Set oWS = WScript.CreateObject(\"WScript.Shell\") -sLinkFile = \"{tmp_path}\\{app_name} Tray.lnk\" - -Set oLink = oWS.CreateShortcut(sLinkFile) - oLink.TargetPath = \"{exe}\" - oLink.Arguments = \"--tray\" - {shortcut_icon_location} -oLink.Save - ", - app_name = crate::get_app_name(), - ), - "vbs", - "tray_shortcut", - )? - .to_str() - .unwrap_or("") - .to_owned()) -} - fn get_import_config(exe: &str) -> String { if config::is_outgoing_only() { return "".to_string(); } + let exe = escape_nested_cmd_ampersands(exe); + let config_path = Config::file(); + let config_path = escape_nested_cmd_ampersands(config_path.to_str().unwrap_or("")); format!(" sc stop {app_name} sc delete {app_name} @@ -3697,7 +3724,6 @@ sc stop {app_name} sc delete {app_name} ", app_name = crate::get_app_name(), - config_path=Config::file().to_str().unwrap_or(""), ) } @@ -3711,6 +3737,7 @@ fn get_create_service(exe: &str) -> String { if exist \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" del /f /q \"%PROGRAMDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\{app_name} Tray.lnk\" ", app_name = crate::get_app_name()) } else { + let exe = escape_nested_cmd_ampersands(exe); format!(" sc create {app_name} binpath= \"\\\"{exe}\\\" --service\" start= auto DisplayName= \"{app_name} Service\" sc start {app_name} @@ -4608,6 +4635,28 @@ mod tests { assert_eq!(chr, None) } + #[test] + fn install_app_names_enforce_ascii_command_safety() { + assert!(validate_install_app_name("RustDesk-Admin1").is_ok()); + for app_name in ["", "RustDesk_Admin", "RustDesk&whoami", "RustDesk应用"] { + assert!( + validate_install_app_name(app_name).is_err(), + "unsafe application name was accepted: {app_name}" + ); + } + } + + #[test] + fn vbs_files_use_utf16le_with_bom_and_crlf() { + const EXPECTED: &[u8] = &[0xFF, 0xFE, b'a', 0, b'\r', 0, b'\n', 0, b'b', 0]; + let tip = format!("vbs_encoding_{}", uuid::Uuid::new_v4().simple()); + let path = write_vbs("a\nb".to_owned(), &tip).expect("VBS file should be written"); + let bytes = std::fs::read(&path).expect("VBS file should be readable"); + std::fs::remove_file(path).expect("VBS file should be removed"); + + assert_eq!(bytes, EXPECTED); + } + #[cfg(not(target_pointer_width = "64"))] #[test] fn test_get_pids_with_args_from_wmic_output() { diff --git a/src/platform/windows/installer_handoff.rs b/src/platform/windows/installer_handoff.rs new file mode 100644 index 00000000000..6e2380cf820 --- /dev/null +++ b/src/platform/windows/installer_handoff.rs @@ -0,0 +1,288 @@ +use super::{ + installer_shell::{ + get_system_executable, path_for_cmd_assignment, path_for_cmd_environment, + run_elevated_and_wait, trusted_install_environment, + BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE, CMD_RELATIVE_PATH, + }, + validate_install_app_name, ResultType, +}; +use hbb_common::{ + bail, log, + sha2::{Digest, Sha256}, +}; +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +const CERTUTIL_RELATIVE_PATH: &str = "certutil.exe"; +const CHCP_RELATIVE_PATH: &str = "chcp.com"; +const FINDSTR_RELATIVE_PATH: &str = "findstr.exe"; +const UTF8_CODE_PAGE: u32 = 65001; +const INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE: u32 = 0x5253_0001; +const INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE: u32 = 0x5253_0002; +const INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE: u32 = 0x5253_0003; +const INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE: u32 = 0x5253_0004; +const BATCH_CODE_PAGE_FAILURE_EXIT_CODE: u32 = 0x5253_0005; +const BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE: u32 = 0x5253_0006; +const BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE: u32 = 0x5253_0007; +const SHA256_HASH_LENGTH: usize = 32; + +type BatchHash = [u8; SHA256_HASH_LENGTH]; + +struct InstallCommandScript { + path: PathBuf, + expected_hash: BatchHash, +} + +impl Drop for InstallCommandScript { + fn drop(&mut self) { + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + log::warn!( + "Failed to remove temporary installer file {:?}: {err}", + self.path + ); + } + } + } +} + +fn prepare_install_commands(commands: &str) -> ResultType { + let commands = commands.replace("\r\n", "\n").replace('\n', "\r\n"); + let chcp_path = get_system_executable(CHCP_RELATIVE_PATH)?; + let chcp = path_for_cmd_environment(&chcp_path)?; + Ok(format!( + "@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n\ + \"{chcp}\" {UTF8_CODE_PAGE} > nul || exit /b \ + {BATCH_CODE_PAGE_FAILURE_EXIT_CODE}\r\n\ + {}\r\n\ + if exist \"%~f0.dir\" exit /b {BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE}\r\n\ + md \"%~f0.dir\" || exit /b {BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE}\r\n\ + set \"RUSTDESK_OUTPUT_DIR=%~f0.dir\"\r\n{commands}\r\nexit /b 0\r\n", + trusted_install_environment()? + )) +} + +fn write_install_script(cmds: String) -> ResultType { + let directory = std::env::temp_dir(); + path_for_cmd_environment(&directory)?; + let commands = prepare_install_commands(&cmds)?; + let expected_hash = Sha256::digest(commands.as_bytes()).into(); + let path = directory.join(format!( + "rustdesk_install_{}.bat", + uuid::Uuid::new_v4().simple() + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + let script = InstallCommandScript { + path, + expected_hash, + }; + file.write_all(commands.as_bytes())?; + file.sync_all()?; + Ok(script) +} + +fn install_hash_pattern(hash: &BatchHash) -> String { + hash.iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *") +} + +fn verified_install_bootstrap( + script: &InstallCommandScript, + runner_directory: &Path, +) -> ResultType { + let source = path_for_cmd_assignment(&script.path)?; + let runner = runner_directory.join(format!( + "rustdesk_install_{}.bat", + uuid::Uuid::new_v4().simple() + )); + let runner = path_for_cmd_assignment(&runner)?; + let cmd_path = get_system_executable(CMD_RELATIVE_PATH)?; + let certutil_path = get_system_executable(CERTUTIL_RELATIVE_PATH)?; + let findstr_path = get_system_executable(FINDSTR_RELATIVE_PATH)?; + let cmd = path_for_cmd_assignment(&cmd_path)?; + let certutil = path_for_cmd_assignment(&certutil_path)?; + let findstr = path_for_cmd_assignment(&findstr_path)?; + // Short names preserve headroom under the Windows 7 ShellExecuteExW 2,048 + // UTF-16-character parameter limit. Paths are stored with delayed expansion + // disabled, then expanded indirectly so literal `!` survives: S=source, + // R=runner, Q=cmd.exe, H=certutil.exe, F=findstr.exe, C=created flag, E=exit code. + Ok(format!( + "setlocal DisableDelayedExpansion & set \"S={source}\" & set \"R={runner}\" & \ + set \"Q={cmd}\" & set \"H={certutil}\" & set \"F={findstr}\" & \ + set \"C=0\" & set \"E=0\" & setlocal EnableDelayedExpansion & \ + if exist \"!R!\" (set \"E={INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE}\") else (\ + set \"C=1\" & copy /Y \"!S!\" \"!R!\" > nul || \ + (set \"E={INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!H!\" -hashfile \"!R!\" SHA256 > \"!R!.hash\" || \ + set \"E={INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!F!\" /R /I /X /C:\"{}\" \"!R!.hash\" > nul || \ + set \"E={INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE}\") & \ + if \"!E!\"==\"0\" (\"!Q!\" /D /E:ON /V:OFF /C \"\"!R!\"\" & \ + set \"E=!errorlevel!\")) & \ + if \"!C!\"==\"1\" (rd /s /q \"!R!.dir\" > nul 2>&1 & \ + del /f /q \"!R!\" \"!R!.*\" > nul 2>&1) & exit /b !E!", + install_hash_pattern(&script.expected_hash), + )) +} + +fn verified_install_parameters(script: &InstallCommandScript) -> ResultType { + let system_directory = get_system_executable("")?; + Ok(format!( + "/D /E:ON /V:ON /C {}", + verified_install_bootstrap(script, &system_directory)? + )) +} + +pub(super) fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { + validate_install_app_name(&crate::get_app_name())?; + let script = write_install_script(cmds)?; + let cmd_path = get_system_executable(CMD_RELATIVE_PATH)?; + let parameters = verified_install_parameters(&script)?; + let exit_code = run_elevated_and_wait(&cmd_path, ¶meters, show)?; + if exit_code != 0 { + bail!( + "{tip} failed with elevated exit code {exit_code}: {}", + elevated_install_failure_reason(exit_code) + ); + } + Ok(()) +} + +fn elevated_install_failure_reason(exit_code: u32) -> &'static str { + match exit_code { + INSTALL_HANDOFF_RUNNER_EXISTS_EXIT_CODE => "protected runner already exists", + INSTALL_HANDOFF_COPY_FAILURE_EXIT_CODE => "failed to copy protected runner", + INSTALL_HANDOFF_HASH_FAILURE_EXIT_CODE => "failed to hash protected runner", + INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE => "protected runner hash mismatch", + BATCH_CODE_PAGE_FAILURE_EXIT_CODE => "failed to set the installer code page", + BATCH_OUTPUT_DIRECTORY_EXISTS_EXIT_CODE => "installer output directory already exists", + BATCH_OUTPUT_DIRECTORY_CREATE_FAILURE_EXIT_CODE => { + "failed to create the installer output directory" + } + BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE => "failed to decode an embedded shortcut", + _ => "installer command failed", + } +} + +#[cfg(test)] +mod tests { + use super::super::installer_shell::{ + embedded_shortcut_commands, shortcut_bytes, WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS, + }; + use super::*; + use ::windows::Win32::System::Threading; + use std::os::windows::process::CommandExt; + + #[test] + fn native_install_handoff_verifies_before_execution() { + let marker = std::env::temp_dir().join(format!( + "rustdesk_install_marker_{}", + uuid::Uuid::new_v4().simple() + )); + let runner_dir = std::env::temp_dir().join(format!( + "rustdesk_install_!RUSTDESK_HANDOFF_EXPAND!&^@()runner_{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir(&runner_dir).expect("runner directory should be created"); + let shortcut_commands = embedded_shortcut_commands( + shortcut_bytes(r"C:\RustDesk.exe", None, None) + .expect("native shortcut should be generated"), + "test.lnk", + "test", + ); + let script = write_install_script(format!( + "if \"%PROGRAMDATA%\"==\"rustdesk_untrusted\" exit /b 77\r\n\ + if \"%PUBLIC%\"==\"rustdesk_untrusted\" exit /b 77\r\n\ + {shortcut_commands}\r\n\ + > \"{}\" echo verified", + marker.display() + )) + .expect("install script should be created"); + let bootstrap = verified_install_bootstrap(&script, &runner_dir) + .expect("native verifier bootstrap should be generated"); + assert_native_handoff_structure(&script, &shortcut_commands, &bootstrap); + + let output = run_install_bootstrap_for_test(&bootstrap); + assert!( + output.status.success(), + "unchanged script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(marker.exists(), "verified install script must execute"); + std::fs::remove_file(&marker).expect("test marker should be removed"); + assert_replaced_install_script_is_rejected(&script, &runner_dir, &marker); + std::fs::remove_dir(runner_dir).expect("runner directory should be empty"); + } + + fn assert_native_handoff_structure( + script: &InstallCommandScript, + shortcut_commands: &str, + bootstrap: &str, + ) { + assert!(shortcut_commands.contains("certutil")); + assert!(shortcut_commands.contains("-decode")); + assert!(!shortcut_commands.to_ascii_lowercase().contains("cscript")); + assert!(!shortcut_commands + .to_ascii_lowercase() + .contains("powershell")); + let win7_hash_pattern = script + .expected_hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" *"); + assert!(bootstrap.contains(&format!("/R /I /X /C:\"{win7_hash_pattern}\""))); + let parameters = + verified_install_parameters(script).expect("elevated parameters should be generated"); + assert!(bootstrap.contains("certutil.exe")); + assert!(bootstrap.contains("findstr.exe")); + assert!(!bootstrap.to_ascii_lowercase().contains("powershell")); + assert!(parameters.encode_utf16().count() < WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS); + } + + fn assert_replaced_install_script_is_rejected( + script: &InstallCommandScript, + runner_dir: &Path, + marker: &Path, + ) { + std::fs::write( + &script.path, + format!("> \"{}\" echo hijacked\r\n", marker.display()), + ) + .expect("install script should be replaceable"); + let replaced = verified_install_bootstrap(&script, &runner_dir) + .expect("replacement verifier should be generated"); + let output = run_install_bootstrap_for_test(&replaced); + assert!( + !output.status.success(), + "replaced script unexpectedly passed verification" + ); + assert_eq!( + output.status.code(), + Some(INSTALL_HANDOFF_HASH_MISMATCH_EXIT_CODE as i32) + ); + assert!(!marker.exists(), "replaced script must not execute"); + } + + fn run_install_bootstrap_for_test(bootstrap: &str) -> std::process::Output { + let cmd = get_system_executable(CMD_RELATIVE_PATH).expect("system cmd.exe should resolve"); + let mut command = std::process::Command::new(cmd); + command + .env("PROGRAMDATA", "rustdesk_untrusted") + .env("PUBLIC", "rustdesk_untrusted") + .env("RUSTDESK_HANDOFF_EXPAND", "expanded"); + command.raw_arg(format!("/D /E:ON /V:ON /C {bootstrap}")); + command + .creation_flags(Threading::CREATE_NO_WINDOW.0) + .output() + .expect("native verifier should run") + } +} diff --git a/src/platform/windows/installer_shell.rs b/src/platform/windows/installer_shell.rs new file mode 100644 index 00000000000..7cbd80608fb --- /dev/null +++ b/src/platform/windows/installer_shell.rs @@ -0,0 +1,300 @@ +use super::{wide_string, ResultType}; +use hbb_common::{ + anyhow::anyhow, + bail, + base64::{engine::general_purpose::STANDARD, Engine as _}, + log, +}; +use std::{ + ffi::OsString, + io, mem, + os::windows::ffi::OsStringExt, + path::{Path, PathBuf}, +}; +use windows::{ + core::{Interface, PCWSTR}, + Win32::{ + Foundation::{self, CloseHandle, HANDLE}, + System::{Com, SystemInformation, Threading}, + UI::{ + Shell::{ + self, FOLDERID_ProgramData, FOLDERID_Public, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }, + WindowsAndMessaging, + }, + }, +}; + +pub(super) const CMD_RELATIVE_PATH: &str = "cmd.exe"; +pub(super) const BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE: u32 = 0x5253_0008; +pub(super) const WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS: usize = 2048; +const SHORTCUT_ICON_INDEX: i32 = 0; + +pub(super) fn shortcut_bytes( + target_path: &str, + arguments: Option<&str>, + icon_location: Option<&str>, +) -> ResultType> { + let _com = initialize_shell_com()?; + let link: Shell::IShellLinkW = + unsafe { Com::CoCreateInstance(&Shell::ShellLink, None, Com::CLSCTX_INPROC_SERVER) }?; + let target_path = wide_string(target_path); + unsafe { link.SetPath(PCWSTR(target_path.as_ptr())) }?; + if let Some(arguments) = arguments { + let arguments = wide_string(arguments); + unsafe { link.SetArguments(PCWSTR(arguments.as_ptr())) }?; + } + if let Some(icon_location) = icon_location { + let icon_location = wide_string(icon_location); + unsafe { link.SetIconLocation(PCWSTR(icon_location.as_ptr()), SHORTCUT_ICON_INDEX) }?; + } + + let stream = unsafe { Shell::SHCreateMemStream(None) } + .ok_or_else(|| anyhow!("Failed to create shortcut memory stream"))?; + let persist: Com::IPersistStream = link.cast()?; + unsafe { persist.Save(&stream, true) }?; + let mut stat = Com::STATSTG::default(); + unsafe { stream.Stat(&mut stat, Com::STATFLAG_NONAME) }?; + let size = usize::try_from(stat.cbSize).map_err(|_| anyhow!("Shortcut data is too large"))?; + let read_size = u32::try_from(size).map_err(|_| anyhow!("Shortcut data is too large"))?; + let mut bytes = vec![0; size]; + let mut bytes_read = 0; + unsafe { + stream.Seek(0, Com::STREAM_SEEK_SET, None)?; + stream + .Read(bytes.as_mut_ptr().cast(), read_size, Some(&mut bytes_read)) + .ok()?; + } + if bytes_read != read_size { + bail!("Failed to read complete shortcut data"); + } + Ok(bytes) +} + +pub(super) fn embedded_shortcut_commands(bytes: Vec, filename: &str, name: &str) -> String { + let encoded = STANDARD.encode(bytes); + let encoded_path = format!("%~f0.{name}.b64"); + format!( + "> \"{encoded_path}\" echo {encoded}\r\n\ + certutil -f -decode \"{encoded_path}\" \"%RUSTDESK_OUTPUT_DIR%\\{filename}\" > nul || exit /b {BATCH_SHORTCUT_DECODE_FAILURE_EXIT_CODE}" + ) +} + +pub(super) fn embedded_tray_shortcut_commands( + app_name: &str, + exe: &str, + icon_location: Option<&str>, +) -> ResultType { + let filename = format!("{app_name} Tray.lnk"); + Ok(embedded_shortcut_commands( + shortcut_bytes(exe, Some("--tray"), icon_location)?, + &filename, + "tray_shortcut", + )) +} + +pub(super) fn validate_install_value(value: &str) -> ResultType<()> { + if value.contains(['\0', '"', '%', '\r', '\n', '|', '<', '>']) { + bail!("Installer path or name contains characters unsafe for cmd.exe"); + } + Ok(()) +} + +pub(super) fn get_system_executable(relative_path: &str) -> ResultType { + let mut buffer = vec![0u16; Foundation::MAX_PATH as usize]; + let len = unsafe { SystemInformation::GetSystemDirectoryW(Some(&mut buffer)) } as usize; + if len == 0 { + return Err(io::Error::last_os_error().into()); + } + if len >= buffer.len() { + bail!("Windows system directory path is too long"); + } + buffer.truncate(len); + let mut path = PathBuf::from(OsString::from_wide(&buffer)); + path.push(relative_path); + Ok(path) +} + +fn get_known_folder(id: &windows::core::GUID) -> ResultType { + let value = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, None) }?; + let path = unsafe { value.to_string() }; + unsafe { Com::CoTaskMemFree(Some(value.0.cast())) }; + Ok(PathBuf::from(path?)) +} + +// `%VAR%` is expanded before cmd.exe executes even inside a quoted `set` assignment. +// Reject all `%` so a handoff path cannot alter the elevated bootstrap before hash verification. +// https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/set_1 +pub(super) fn path_for_cmd_environment(path: &Path) -> ResultType<&str> { + let value = path + .to_str() + .ok_or_else(|| anyhow!("Path is not valid Unicode: {:?}", path))?; + if value.contains(['\0', '"', '%', '\r', '\n']) { + bail!("Path is unsafe for an elevated cmd.exe handoff: {:?}", path); + } + Ok(value) +} + +// Bootstrap assignments are parsed before DisableDelayedExpansion takes effect. +// Escape carets first so `^!` preserves each literal exclamation mark. +pub(super) fn path_for_cmd_assignment(path: &Path) -> ResultType { + Ok(path_for_cmd_environment(path)? + .replace('^', "^^") + .replace('!', "^!")) +} + +pub(super) fn trusted_install_environment() -> ResultType { + let system = get_system_executable("")?; + let program_data = get_known_folder(&FOLDERID_ProgramData)?; + let public = get_known_folder(&FOLDERID_Public)?; + trusted_install_environment_from_paths(&system, &program_data, &public) +} + +fn trusted_install_environment_from_paths( + system: &Path, + program_data: &Path, + public: &Path, +) -> ResultType { + let windows = system + .parent() + .ok_or_else(|| anyhow!("System directory has no parent"))?; + let cmd = system.join(CMD_RELATIVE_PATH); + // These paths are parsed once from the protected BAT, with delayed expansion disabled. + let system = path_for_cmd_environment(system)?; + let windows = path_for_cmd_environment(windows)?; + let cmd = path_for_cmd_environment(&cmd)?; + let program_data = path_for_cmd_environment(program_data)?; + let public = path_for_cmd_environment(public)?; + Ok(format!( + "set \"ComSpec={cmd}\" & set \"PATH={system}\" & \ + set \"SystemRoot={windows}\" & set \"WINDIR={windows}\" & \ + set \"ProgramData={program_data}\" & set \"PUBLIC={public}\" & \ + set \"PATHEXT=.COM;.EXE;.BAT;.CMD\" & \ + set \"NoDefaultCurrentDirectoryInExePath=1\"" + )) +} + +struct ShellComGuard; + +impl Drop for ShellComGuard { + fn drop(&mut self) { + unsafe { Com::CoUninitialize() }; + } +} + +fn initialize_shell_com() -> ResultType> { + let result = unsafe { + Com::CoInitializeEx( + None, + Com::COINIT_APARTMENTTHREADED | Com::COINIT_DISABLE_OLE1DDE, + ) + }; + if result == Foundation::RPC_E_CHANGED_MODE { + return Ok(None); + } + if result.is_err() { + bail!( + "Failed to initialize COM: HRESULT 0x{:08X}", + result.0 as u32 + ); + } + Ok(Some(ShellComGuard)) +} + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + if let Err(err) = unsafe { CloseHandle(self.0) } { + log::warn!("Failed to close elevated process handle: {err}"); + } + } +} + +fn elevated_working_directory(executable: &Path) -> ResultType<&Path> { + executable + .parent() + .ok_or_else(|| anyhow!("Elevated executable has no parent directory")) +} + +pub(super) fn run_elevated_and_wait( + executable: &Path, + parameters: &str, + show: bool, +) -> ResultType { + let parameter_chars = parameters.encode_utf16().count(); + if parameter_chars >= WIN7_SHELL_EXECUTE_MAX_PARAMETER_CHARS { + bail!("Elevated command is too long: {parameter_chars} UTF-16 characters"); + } + let _com = initialize_shell_com()?; + let verb = wide_string("runas"); + let working_directory = wide_string(path_for_cmd_environment(elevated_working_directory( + executable, + )?)?); + let executable = wide_string(path_for_cmd_environment(executable)?); + let parameters = wide_string(parameters); + let mut info = Shell::SHELLEXECUTEINFOW::default(); + info.cbSize = mem::size_of::() as u32; + info.fMask = Shell::SEE_MASK_NOCLOSEPROCESS | Shell::SEE_MASK_NOASYNC; + info.lpVerb = PCWSTR(verb.as_ptr()); + info.lpFile = PCWSTR(executable.as_ptr()); + info.lpParameters = PCWSTR(parameters.as_ptr()); + info.lpDirectory = PCWSTR(working_directory.as_ptr()); + info.nShow = if show { + WindowsAndMessaging::SW_SHOWNORMAL.0 + } else { + WindowsAndMessaging::SW_HIDE.0 + }; + unsafe { Shell::ShellExecuteExW(&mut info) }?; + if info.hProcess.0.is_null() { + bail!("Windows did not return an elevated process handle"); + } + let process = ProcessHandle(info.hProcess); + let wait_result = unsafe { Threading::WaitForSingleObject(process.0, Threading::INFINITE) }; + if wait_result == Foundation::WAIT_FAILED { + return Err(io::Error::last_os_error().into()); + } + if wait_result != Foundation::WAIT_OBJECT_0 { + bail!("Unexpected elevated process wait result: {}", wait_result.0); + } + let mut exit_code = 0; + unsafe { Threading::GetExitCodeProcess(process.0, &mut exit_code) }?; + Ok(exit_code) +} + +// Escape `^` before using it to escape `&` so both survive nested cmd.exe parsing. +// https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/cc754250(v=ws.11) +pub(super) fn escape_nested_cmd_ampersands(value: &str) -> String { + value.replace('^', "^^").replace('&', "^&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_values_enforce_command_safety() { + assert!(validate_install_value(r"C:\safe ! path").is_ok()); + assert!(validate_install_value(r"C:\Program Files (x86)\RustDesk").is_ok()); + assert!(validate_install_value(r"C:\Users\R&D\RustDesk.exe").is_ok()); + assert!(validate_install_value(r"C:\A&^ B\RustDesk.exe").is_ok()); + for character in ['\0', '"', '%', '\r', '\n', '|', '<', '>'] { + let value = format!(r"C:\unsafe{character}path"); + assert!( + validate_install_value(&value).is_err(), + "cmd.exe control character was accepted: {character:?}" + ); + } + } + + #[test] + fn nested_commands_escape_while_protected_environment_preserves_carets() { + assert_eq!( + escape_nested_cmd_ampersands(r"C:\A&^ B\RustDesk.exe"), + r"C:\A^&^^ B\RustDesk.exe" + ); + let path = Path::new(r"C:\Win^Root\System32"); + let environment = trusted_install_environment_from_paths(path, path, path).unwrap(); + assert!(environment.contains(r#"set "PATH=C:\Win^Root\System32""#)); + } +} diff --git a/src/plugin/callback_ext.rs b/src/plugin/callback_ext.rs deleted file mode 100644 index 715f47f7e45..00000000000 --- a/src/plugin/callback_ext.rs +++ /dev/null @@ -1,44 +0,0 @@ -// External support for callback. -// 1. Support block input for some plugins. -// ----------------------------------------------------------------------------- - -use super::*; - -const EXT_SUPPORT_BLOCK_INPUT: &str = "block-input"; - -pub(super) fn ext_support_callback( - id: &str, - peer: &str, - msg: &super::callback_msg::MsgToExtSupport, -) -> PluginReturn { - match &msg.r#type as _ { - EXT_SUPPORT_BLOCK_INPUT => { - // let supported_plugins = []; - // let supported = supported_plugins.contains(&id); - let supported = true; - if supported { - if msg.data.len() != 1 { - return PluginReturn::new( - errno::ERR_CALLBACK_INVALID_ARGS, - "Invalid data length", - ); - } - let block = msg.data[0] != 0; - if crate::server::plugin_block_input(peer, block) == block { - PluginReturn::success() - } else { - PluginReturn::new(errno::ERR_CALLBACK_FAILED, "") - } - } else { - PluginReturn::new( - errno::ERR_CALLBACK_PLUGIN_ID, - &format!("This operation is not supported for plugin '{}', please contact the RustDesk team for support.", id), - ) - } - } - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!("Unknown target type '{}'", &msg.r#type), - ), - } -} diff --git a/src/plugin/callback_msg.rs b/src/plugin/callback_msg.rs deleted file mode 100644 index 2a23b03dd10..00000000000 --- a/src/plugin/callback_msg.rs +++ /dev/null @@ -1,411 +0,0 @@ -use super::*; -use crate::hbbs_http::create_http_client; -use crate::{ - flutter::{self, APP_TYPE_CM, APP_TYPE_MAIN, SESSIONS}, - ui_interface::get_api_server, -}; -use hbb_common::{lazy_static, log, message_proto::PluginRequest}; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::{ - collections::HashMap, - ffi::{c_char, c_void}, - sync::Arc, - thread, - time::Duration, -}; - -const MSG_TO_RUSTDESK_TARGET: &str = "rustdesk"; -const MSG_TO_PEER_TARGET: &str = "peer"; -const MSG_TO_UI_TARGET: &str = "ui"; -const MSG_TO_CONFIG_TARGET: &str = "config"; -const MSG_TO_EXT_SUPPORT_TARGET: &str = "ext-support"; - -const MSG_TO_RUSTDESK_SIGNATURE_VERIFICATION: &str = "signature_verification"; - -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_MAIN: u16 = 0x01 << 0; -#[allow(dead_code)] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -const MSG_TO_UI_FLUTTER_CHANNEL_CM: u16 = 0x01 << 1; -#[cfg(any(target_os = "android", target_os = "ios"))] -const MSG_TO_UI_FLUTTER_CHANNEL_CM: u16 = 0x01; -const MSG_TO_UI_FLUTTER_CHANNEL_REMOTE: u16 = 0x01 << 2; -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_TRANSFER: u16 = 0x01 << 3; -#[allow(dead_code)] -const MSG_TO_UI_FLUTTER_CHANNEL_FORWARD: u16 = 0x01 << 4; - -lazy_static::lazy_static! { - static ref MSG_TO_UI_FLUTTER_CHANNELS: Arc> = { - let channels = HashMap::from([ - (MSG_TO_UI_FLUTTER_CHANNEL_MAIN, APP_TYPE_MAIN.to_string()), - (MSG_TO_UI_FLUTTER_CHANNEL_CM, APP_TYPE_CM.to_string()), - ]); - Arc::new(channels) - }; -} - -#[derive(Deserialize)] -pub struct MsgToRustDesk { - pub r#type: String, - pub data: Vec, -} - -#[derive(Deserialize)] -pub struct SignatureVerification { - pub version: String, - pub data: Vec, -} - -#[derive(Debug, Deserialize)] -struct ConfigToUi { - channel: u16, - location: String, -} - -#[derive(Debug, Deserialize)] -struct MsgToConfig { - r#type: String, - key: String, - value: String, - #[serde(skip_serializing_if = "Option::is_none")] - ui: Option, // If not None, send msg to ui. -} - -#[derive(Debug, Deserialize)] -pub(super) struct MsgToExtSupport { - pub r#type: String, - pub data: Vec, -} - -#[derive(Debug, Serialize)] -struct PluginSignReq { - plugin_id: String, - version: String, - msg: Vec, -} - -#[derive(Debug, Deserialize)] -struct PluginSignResp { - signed_msg: Vec, -} - -macro_rules! cb_msg_field { - ($field: ident) => { - let $field = match cstr_to_string($field) { - Err(e) => { - let msg = format!("Failed to convert {} to string, {}", stringify!($field), e); - log::error!("{}", &msg); - return PluginReturn::new(errno::ERR_CALLBACK_INVALID_ARGS, &msg); - } - Ok(v) => v, - }; - }; -} - -macro_rules! early_return_value { - ($e:expr, $code: ident, $($arg:tt)*) => { - match $e { - Err(e) => return PluginReturn::new( - errno::$code, - &format!("Failed to {} '{}'", format_args!($($arg)*), e), - ), - Ok(v) => v, - } - }; -} - -/// Callback to send message to peer or ui. -/// peer, target, id are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// target: "peer" or "ui". -/// id: The id of this plugin. -/// content: The content. -/// len: The length of the content. -/// -/// Return null ptr if success. -/// Return the error message if failed. `i32-String` without dash, i32 is a signed little-endian number, the String is utf8 string. -/// The plugin allocate memory with `libc::malloc` and return the pointer. -#[no_mangle] -pub(super) extern "C" fn cb_msg( - peer: *const c_char, - target: *const c_char, - id: *const c_char, - content: *const c_void, - len: usize, -) -> PluginReturn { - cb_msg_field!(target); - cb_msg_field!(id); - - match &target as _ { - MSG_TO_PEER_TARGET => { - cb_msg_field!(peer); - if let Some(session) = SESSIONS.write().unwrap().get_mut(&peer) { - let content_slice = - unsafe { std::slice::from_raw_parts(content as *const u8, len) }; - let content_vec = Vec::from(content_slice); - let request = PluginRequest { - id, - content: bytes::Bytes::from(content_vec), - ..Default::default() - }; - session.send_plugin_request(request); - PluginReturn::success() - } else { - PluginReturn::new( - errno::ERR_CALLBACK_PEER_NOT_FOUND, - &format!("Failed to find session for peer '{}'", peer), - ) - } - } - MSG_TO_UI_TARGET => { - cb_msg_field!(peer); - let content_slice = unsafe { std::slice::from_raw_parts(content as *const u8, len) }; - let channel = u16::from_le_bytes([content_slice[0], content_slice[1]]); - let content = std::string::String::from_utf8(content_slice[2..].to_vec()) - .unwrap_or("".to_string()); - push_event_to_ui(channel, &peer, &content); - PluginReturn::success() - } - MSG_TO_CONFIG_TARGET => { - cb_msg_field!(peer); - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - // No need to merge the msgs. Handling the msg one by one is ok. - let msg = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - match &msg.r#type as _ { - config::CONFIG_TYPE_SHARED => { - let _r = early_return_value!( - config::SharedConfig::set(&id, &msg.key, &msg.value), - ERR_CALLBACK_INVALID_MSG, - "set local config" - ); - if let Some(ui) = &msg.ui { - // No need to set the peer id for location config. - push_option_to_ui(ui.channel, &id, "", &msg, ui); - } - PluginReturn::success() - } - config::CONFIG_TYPE_PEER => { - let _r = early_return_value!( - config::PeerConfig::set(&id, &peer, &msg.key, &msg.value), - ERR_CALLBACK_INVALID_MSG, - "set peer config" - ); - if let Some(ui) = &msg.ui { - push_option_to_ui(ui.channel, &id, &peer, &msg, ui); - } - PluginReturn::success() - } - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!("Unknown target type '{}'", &msg.r#type), - ), - } - } - MSG_TO_EXT_SUPPORT_TARGET => { - cb_msg_field!(peer); - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - let msg = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - super::callback_ext::ext_support_callback(&id, &peer, &msg) - } - MSG_TO_RUSTDESK_TARGET => handle_msg_to_rustdesk(id, content, len), - _ => PluginReturn::new( - errno::ERR_CALLBACK_TARGET, - &format!("Unknown target '{}'", target), - ), - } -} - -#[inline] -fn is_peer_channel(channel: u16) -> bool { - channel & MSG_TO_UI_FLUTTER_CHANNEL_REMOTE != 0 - || channel & MSG_TO_UI_FLUTTER_CHANNEL_TRANSFER != 0 - || channel & MSG_TO_UI_FLUTTER_CHANNEL_FORWARD != 0 -} - -fn handle_msg_to_rustdesk(id: String, content: *const c_void, len: usize) -> PluginReturn { - let s = early_return_value!( - std::str::from_utf8(unsafe { std::slice::from_raw_parts(content as _, len) }), - ERR_CALLBACK_INVALID_MSG, - "parse msg string" - ); - let msg_to_rustdesk = early_return_value!( - serde_json::from_str::(s), - ERR_CALLBACK_INVALID_MSG, - "parse msg '{}'", - s - ); - match &msg_to_rustdesk.r#type as &str { - MSG_TO_RUSTDESK_SIGNATURE_VERIFICATION => request_plugin_sign(id, msg_to_rustdesk), - t => PluginReturn::new( - errno::ERR_CALLBACK_TARGET_TYPE, - &format!( - "Unknown target type '{}' for target {}", - t, MSG_TO_RUSTDESK_TARGET - ), - ), - } -} - -fn request_plugin_sign(id: String, msg_to_rustdesk: MsgToRustDesk) -> PluginReturn { - let signature_data = early_return_value!( - std::str::from_utf8(&msg_to_rustdesk.data), - ERR_CALLBACK_INVALID_MSG, - "parse signature data string" - ); - let signature_data = early_return_value!( - serde_json::from_str::(signature_data), - ERR_CALLBACK_INVALID_MSG, - "parse signature data '{}'", - signature_data - ); - thread::spawn(move || { - let sign_url = format!("{}/lic/web/api/plugin-sign", get_api_server()); - let client = create_http_client(); - let req = PluginSignReq { - plugin_id: id.clone(), - version: signature_data.version, - msg: signature_data.data, - }; - match client - .post(sign_url) - .json(&req) - .timeout(Duration::from_secs(10)) - .send() - { - Ok(response) => match response.json::() { - Ok(sign_resp) => { - match super::plugins::plugin_call( - &id, - super::plugins::METHOD_HANDLE_SIGNATURE_VERIFICATION, - "", - &sign_resp.signed_msg, - ) { - Ok(..) => { - match super::plugins::plugin_call_get_return( - &id, - super::plugins::METHOD_HANDLE_STATUS, - "", - &[], - ) { - Ok(ret) => { - debug_assert!(!ret.msg.is_null(), "msg is null"); - if ret.msg.is_null() { - // unreachable - log::error!( - "The returned message pointer of plugin status is null, plugin id: '{}', code: {}", - id, - ret.code, - ); - return; - } - let msg = cstr_to_string(ret.msg).unwrap_or_default(); - free_c_ptr(ret.msg as _); - if ret.code == super::errno::ERR_SUCCESS { - log::info!("Plugin '{}' status: '{}'", id, msg); - } else { - log::error!( - "Failed to handle plugin event, id: {}, method: {}, code: {}, msg: {}", - id, - std::string::String::from_utf8(super::plugins::METHOD_HANDLE_STATUS.to_vec()).unwrap_or_default(), - ret.code, - msg - ); - } - } - Err(e) => { - log::error!( - "Failed to call status for plugin '{}': {}", - &id, - e - ); - } - } - } - Err(e) => { - log::error!( - "Failed to call signature verification for plugin '{}': {}", - &id, - e - ); - } - } - } - Err(e) => { - log::error!("Failed to decode response for plugin '{}': {}", &id, e); - } - }, - Err(e) => { - log::error!("Failed to request sign for plugin '{}', {}", &id, e); - } - } - }); - PluginReturn::success() -} - -fn push_event_to_ui(channel: u16, peer: &str, content: &str) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_EVENT); - m.insert("peer", &peer); - m.insert("content", &content); - let event = serde_json::to_string(&m).unwrap_or("".to_string()); - // Send to main and cm - for (k, v) in MSG_TO_UI_FLUTTER_CHANNELS.iter() { - if channel & k != 0 { - let _res = flutter::push_global_event(v as _, event.to_string()); - } - } - if !peer.is_empty() && is_peer_channel(channel) { - let _res = flutter::push_session_event( - &peer, - MSG_TO_UI_TYPE_PLUGIN_EVENT, - vec![("peer", &peer), ("content", &content)], - ); - } -} - -fn push_option_to_ui(channel: u16, id: &str, peer: &str, msg: &MsgToConfig, ui: &ConfigToUi) { - let v = [ - ("id", id), - ("location", &ui.location), - ("key", &msg.key), - ("value", &msg.value), - ]; - - // Send main and cm - let mut m = HashMap::from(v); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_OPTION); - let event = serde_json::to_string(&m).unwrap_or("".to_string()); - for (k, v) in MSG_TO_UI_FLUTTER_CHANNELS.iter() { - if channel & k != 0 { - let _res = flutter::push_global_event(v as _, event.to_string()); - } - } - - // Send remote, transfer and forward - if !peer.is_empty() && is_peer_channel(channel) { - let mut v = v.to_vec(); - v.push(("peer", &peer)); - let _res = flutter::push_session_event(&peer, MSG_TO_UI_TYPE_PLUGIN_OPTION, v); - } -} diff --git a/src/plugin/config.rs b/src/plugin/config.rs deleted file mode 100644 index 20cd02a883d..00000000000 --- a/src/plugin/config.rs +++ /dev/null @@ -1,363 +0,0 @@ -use super::{cstr_to_string, str_to_cstr_ret}; -use hbb_common::{allow_err, bail, config::Config as HbbConfig, lazy_static, log, ResultType}; -use serde_derive::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - ffi::c_char, - fs, - ops::{Deref, DerefMut}, - path::PathBuf, - ptr, - str::FromStr, - sync::{Arc, Mutex}, -}; - -lazy_static::lazy_static! { - static ref CONFIG_SHARED: Arc>> = Default::default(); - static ref CONFIG_PEERS: Arc>> = Default::default(); - static ref CONFIG_MANAGER: Arc> = { - let conf = hbb_common::config::load_path::(ManagerConfig::path()); - Arc::new(Mutex::new(conf)) - }; -} -use crate::ui_interface::get_id; - -pub(super) const CONFIG_TYPE_SHARED: &str = "shared"; -pub(super) const CONFIG_TYPE_PEER: &str = "peer"; - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct SharedConfig(HashMap); -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct PeerConfig(HashMap); -type PeersConfig = HashMap; - -#[inline] -fn path_plugins(id: &str) -> PathBuf { - HbbConfig::path("plugins").join(id) -} - -pub fn remove(id: &str) { - CONFIG_SHARED.lock().unwrap().remove(id); - CONFIG_PEERS.lock().unwrap().remove(id); - // allow_err is Ok here. - allow_err!(ManagerConfig::remove_plugin(id)); - if let Err(e) = fs::remove_dir_all(path_plugins(id)) { - log::error!("Failed to remove plugin '{}' directory: {}", id, e); - } -} - -impl Deref for SharedConfig { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for SharedConfig { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Deref for PeerConfig { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for PeerConfig { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl SharedConfig { - #[inline] - fn path(id: &str) -> PathBuf { - path_plugins(id).join("shared.toml") - } - - #[inline] - fn load(id: &str) { - let mut lock = CONFIG_SHARED.lock().unwrap(); - if lock.contains_key(id) { - return; - } - let conf = hbb_common::config::load_path::>(Self::path(id)); - let mut conf = SharedConfig(conf); - if let Some(desc_conf) = super::plugins::get_desc_conf(id) { - for item in desc_conf.shared.iter() { - if !conf.contains_key(&item.key) { - conf.insert(item.key.to_owned(), item.default.to_owned()); - } - } - } - lock.insert(id.to_owned(), conf); - } - - #[inline] - fn load_if_not_exists(id: &str) { - if CONFIG_SHARED.lock().unwrap().contains_key(id) { - return; - } - Self::load(id); - } - - #[inline] - pub fn get(id: &str, key: &str) -> Option { - Self::load_if_not_exists(id); - CONFIG_SHARED - .lock() - .unwrap() - .get(id)? - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set(id: &str, key: &str, value: &str) -> ResultType<()> { - Self::load_if_not_exists(id); - match CONFIG_SHARED.lock().unwrap().get_mut(id) { - Some(config) => { - config.insert(key.to_owned(), value.to_owned()); - hbb_common::config::store_path(Self::path(id), config) - } - None => { - // unreachable - bail!("No such plugin {}", id) - } - } - } -} - -impl PeerConfig { - #[inline] - fn path(id: &str, peer: &str) -> PathBuf { - path_plugins(id) - .join("peers") - .join(format!("{}.toml", peer)) - } - - #[inline] - fn load(id: &str, peer: &str) { - let mut lock = CONFIG_PEERS.lock().unwrap(); - if let Some(peers) = lock.get(id) { - if peers.contains_key(peer) { - return; - } - } - - let conf = hbb_common::config::load_path::>(Self::path(id, peer)); - let mut conf = PeerConfig(conf); - if let Some(desc_conf) = super::plugins::get_desc_conf(id) { - for item in desc_conf.peer.iter() { - if !conf.contains_key(&item.key) { - conf.insert(item.key.to_owned(), item.default.to_owned()); - } - } - } - - if let Some(peers) = lock.get_mut(id) { - peers.insert(peer.to_owned(), conf); - return; - } - - let mut peers = HashMap::new(); - peers.insert(peer.to_owned(), conf); - lock.insert(id.to_owned(), peers); - } - - #[inline] - fn load_if_not_exists(id: &str, peer: &str) { - if let Some(peers) = CONFIG_PEERS.lock().unwrap().get(id) { - if peers.contains_key(peer) { - return; - } - } - Self::load(id, peer); - } - - #[inline] - pub fn get(id: &str, peer: &str, key: &str) -> Option { - Self::load_if_not_exists(id, peer); - CONFIG_PEERS - .lock() - .unwrap() - .get(id)? - .get(peer)? - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set(id: &str, peer: &str, key: &str, value: &str) -> ResultType<()> { - Self::load_if_not_exists(id, peer); - match CONFIG_PEERS.lock().unwrap().get_mut(id) { - Some(peers) => match peers.get_mut(peer) { - Some(config) => { - config.insert(key.to_owned(), value.to_owned()); - hbb_common::config::store_path(Self::path(id, peer), config) - } - None => { - // unreachable - bail!("No such peer {}", peer) - } - }, - None => { - // unreachable - bail!("No such plugin {}", id) - } - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PluginStatus { - pub enabled: bool, -} - -const MANAGER_VERSION: &str = "0.1.0"; - -#[derive(Debug, Serialize, Deserialize)] -pub struct ManagerConfig { - pub version: String, - #[serde(default)] - pub options: HashMap, - #[serde(default)] - pub plugins: HashMap, -} - -impl Default for ManagerConfig { - fn default() -> Self { - Self { - version: MANAGER_VERSION.to_owned(), - options: HashMap::new(), - plugins: HashMap::new(), - } - } -} - -// Do not care about the `store_path` error, no need to store the old value and restore if failed. -impl ManagerConfig { - #[inline] - fn path() -> PathBuf { - HbbConfig::path("plugins").join("manager.toml") - } - - #[inline] - pub fn get_option(key: &str) -> Option { - CONFIG_MANAGER - .lock() - .unwrap() - .options - .get(key) - .map(|s| s.to_owned()) - } - - #[inline] - pub fn set_option(key: &str, value: &str) { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.options.insert(key.to_owned(), value.to_owned()); - allow_err!(hbb_common::config::store_path(Self::path(), &*lock)); - } - - #[inline] - pub fn get_plugin_option(id: &str, key: &str) -> Option { - let lock = CONFIG_MANAGER.lock().unwrap(); - match key { - "enabled" => { - let enabled = lock - .plugins - .get(id) - .map(|status| status.enabled.to_owned()) - .unwrap_or(true.to_owned()) - .to_string(); - Some(enabled) - } - _ => None, - } - } - - fn set_plugin_option_enabled(id: &str, enabled: bool) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - if let Some(status) = lock.plugins.get_mut(id) { - status.enabled = enabled; - } else { - lock.plugins.insert(id.to_owned(), PluginStatus { enabled }); - } - hbb_common::config::store_path(Self::path(), &*lock) - } - - pub fn set_plugin_option(id: &str, key: &str, value: &str) { - match key { - "enabled" => { - let enabled = bool::from_str(value).unwrap_or(false); - allow_err!(Self::set_plugin_option_enabled(id, enabled)); - if enabled { - allow_err!(super::load_plugin(id)); - } else { - super::unload_plugin(id); - } - } - _ => log::error!("No such option {}", key), - } - } - - #[inline] - pub fn add_plugin(id: &str) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.plugins - .insert(id.to_owned(), PluginStatus { enabled: true }); - hbb_common::config::store_path(Self::path(), &*lock) - } - - #[inline] - pub fn remove_plugin(id: &str) -> ResultType<()> { - let mut lock = CONFIG_MANAGER.lock().unwrap(); - lock.plugins.remove(id); - hbb_common::config::store_path(Self::path(), &*lock) - } -} - -pub(super) extern "C" fn cb_get_local_peer_id() -> *const c_char { - str_to_cstr_ret(&get_id()) -} - -// Return shared config if peer is nullptr. -pub(super) extern "C" fn cb_get_conf( - peer: *const c_char, - id: *const c_char, - key: *const c_char, -) -> *const c_char { - match (cstr_to_string(id), cstr_to_string(key)) { - (Ok(id), Ok(key)) => { - if peer.is_null() { - SharedConfig::load_if_not_exists(&id); - if let Some(conf) = CONFIG_SHARED.lock().unwrap().get(&id) { - if let Some(value) = conf.get(&key) { - return str_to_cstr_ret(value); - } - } - } else { - match cstr_to_string(peer) { - Ok(peer) => { - PeerConfig::load_if_not_exists(&id, &peer); - if let Some(conf) = CONFIG_PEERS.lock().unwrap().get(&id) { - if let Some(conf) = conf.get(&peer) { - if let Some(value) = conf.get(&key) { - return str_to_cstr_ret(value); - } - } - } - } - Err(_) => {} - } - } - } - _ => {} - } - ptr::null() -} diff --git a/src/plugin/desc.rs b/src/plugin/desc.rs deleted file mode 100644 index 883f2afd789..00000000000 --- a/src/plugin/desc.rs +++ /dev/null @@ -1,100 +0,0 @@ -use hbb_common::ResultType; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::collections::HashMap; -use std::ffi::{c_char, CStr}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiButton { - key: String, - text: String, - icon: String, // icon can be int in flutter, but string in other ui framework. And it is flexible to use string. - tooltip: String, - action: String, // The action to be triggered when the button is clicked. -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiCheckbox { - key: String, - text: String, - tooltip: String, - action: String, // The action to be triggered when the checkbox is checked or unchecked. -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "t", content = "c")] -pub enum UiType { - Button(UiButton), - Checkbox(UiCheckbox), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Location { - pub ui: HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigItem { - pub key: String, - pub default: String, - pub description: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub shared: Vec, - pub peer: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PublishInfo { - pub published: String, - pub last_released: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Meta { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - #[serde(default)] - pub platforms: String, - pub author: String, - pub home: String, - pub license: String, - pub source: String, - pub publish_info: PublishInfo, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Desc { - meta: Meta, - need_reboot: bool, - location: Location, - config: Config, - listen_events: Vec, -} - -impl Desc { - pub fn from_cstr(s: *const c_char) -> ResultType { - let s = unsafe { CStr::from_ptr(s) }; - Ok(serde_json::from_str(s.to_str()?)?) - } - - pub fn meta(&self) -> &Meta { - &self.meta - } - - pub fn location(&self) -> &Location { - &self.location - } - - pub fn config(&self) -> &Config { - &self.config - } - - pub fn listen_events(&self) -> &Vec { - &self.listen_events - } -} diff --git a/src/plugin/errno.rs b/src/plugin/errno.rs deleted file mode 100644 index 6b1e3612d5b..00000000000 --- a/src/plugin/errno.rs +++ /dev/null @@ -1,50 +0,0 @@ -#![allow(dead_code)] - -pub const ERR_SUCCESS: i32 = 0; - -// ====================================================== -// Errors from the plugins, must be handled by RustDesk - -pub const ERR_RUSTDESK_HANDLE_BASE: i32 = 10000; - -// not loaded -pub const ERR_PLUGIN_LOAD: i32 = 10001; -// not initialized -pub const ERR_PLUGIN_MSG_INIT: i32 = 10101; -pub const ERR_PLUGIN_MSG_INIT_INVALID: i32 = 10102; -pub const ERR_PLUGIN_MSG_GET_LOCAL_PEER_ID: i32 = 10103; -pub const ERR_PLUGIN_SIGNATURE_NOT_VERIFIED: i32 = 10104; -pub const ERR_PLUGIN_SIGNATURE_VERIFICATION_FAILED: i32 = 10105; -// invalid -pub const ERR_CALL_UNIMPLEMENTED: i32 = 10201; -pub const ERR_CALL_INVALID_METHOD: i32 = 10202; -pub const ERR_CALL_NOT_SUPPORTED_METHOD: i32 = 10203; -pub const ERR_CALL_INVALID_PEER: i32 = 10204; -// failed on calling -pub const ERR_CALL_INVALID_ARGS: i32 = 10301; -pub const ERR_PEER_ID_MISMATCH: i32 = 10302; -pub const ERR_CALL_CONFIG_VALUE: i32 = 10303; -// no handlers on calling -pub const ERR_NOT_HANDLED: i32 = 10401; - -// ====================================================== -// Errors from RustDesk callbacks. - -pub const ERR_CALLBACK_HANDLE_BASE: i32 = 20000; -pub const ERR_CALLBACK_PLUGIN_ID: i32 = 20001; -pub const ERR_CALLBACK_INVALID_ARGS: i32 = 20002; -pub const ERR_CALLBACK_INVALID_MSG: i32 = 20003; -pub const ERR_CALLBACK_TARGET: i32 = 20004; -pub const ERR_CALLBACK_TARGET_TYPE: i32 = 20005; -pub const ERR_CALLBACK_PEER_NOT_FOUND: i32 = 20006; - -pub const ERR_CALLBACK_FAILED: i32 = 21001; - -// ====================================================== -// Errors from the plugins, should be handled by the plugins. - -pub const ERR_PLUGIN_HANDLE_BASE: i32 = 30000; - -pub const EER_CALL_FAILED: i32 = 30021; -pub const ERR_PEER_ON_FAILED: i32 = 40012; -pub const ERR_PEER_OFF_FAILED: i32 = 40012; diff --git a/src/plugin/ipc.rs b/src/plugin/ipc.rs deleted file mode 100644 index 6a14ab00a3c..00000000000 --- a/src/plugin/ipc.rs +++ /dev/null @@ -1,230 +0,0 @@ -// to-do: Interdependence(This mod and crate::ipc) is not good practice here. -use crate::ipc::{connect, Connection, Data}; -use hbb_common::{allow_err, log, tokio, ResultType}; -use serde_derive::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum InstallStatus { - Downloading(u8), - Installing, - Finished, - FailedCreating, - FailedDownloading, - FailedInstalling, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "t", content = "c")] -pub enum Plugin { - Config(String, String, Option), - ManagerConfig(String, Option), - ManagerPluginConfig(String, String, Option), - Load(String), - Reload(String), - InstallStatus((String, InstallStatus)), - Uninstall(String), -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_config(id: &str, name: &str) -> ResultType> { - get_config_async(id, name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_config(id: &str, name: &str, value: String) -> ResultType<()> { - set_config_async(id, name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_manager_config(name: &str) -> ResultType> { - get_manager_config_async(name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_manager_config(name: &str, value: String) -> ResultType<()> { - set_manager_config_async(name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn get_manager_plugin_config(id: &str, name: &str) -> ResultType> { - get_manager_plugin_config_async(id, name, 1_000).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn set_manager_plugin_config(id: &str, name: &str, value: String) -> ResultType<()> { - set_manager_plugin_config_async(id, name, value).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn load_plugin(id: &str) -> ResultType<()> { - load_plugin_async(id).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn reload_plugin(id: &str) -> ResultType<()> { - reload_plugin_async(id).await -} - -#[tokio::main(flavor = "current_thread")] -pub async fn uninstall_plugin(id: &str) -> ResultType<()> { - uninstall_plugin_async(id).await -} - -async fn get_config_async(id: &str, name: &str, ms_timeout: u64) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::Config( - id.to_owned(), - name.to_owned(), - None, - ))) - .await?; - if let Some(Data::Plugin(Plugin::Config(id2, name2, value))) = - c.next_timeout(ms_timeout).await? - { - if id == id2 && name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_config_async(id: &str, name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Config( - id.to_owned(), - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -async fn get_manager_config_async(name: &str, ms_timeout: u64) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::ManagerConfig(name.to_owned(), None))) - .await?; - if let Some(Data::Plugin(Plugin::ManagerConfig(name2, value))) = - c.next_timeout(ms_timeout).await? - { - if name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_manager_config_async(name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::ManagerConfig( - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -async fn get_manager_plugin_config_async( - id: &str, - name: &str, - ms_timeout: u64, -) -> ResultType> { - let mut c = connect(ms_timeout, "").await?; - c.send(&Data::Plugin(Plugin::ManagerPluginConfig( - id.to_owned(), - name.to_owned(), - None, - ))) - .await?; - if let Some(Data::Plugin(Plugin::ManagerPluginConfig(id2, name2, value))) = - c.next_timeout(ms_timeout).await? - { - if id == id2 && name == name2 { - return Ok(value); - } - } - return Ok(None); -} - -async fn set_manager_plugin_config_async(id: &str, name: &str, value: String) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::ManagerPluginConfig( - id.to_owned(), - name.to_owned(), - Some(value), - ))) - .await?; - Ok(()) -} - -pub async fn load_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Load(id.to_owned()))).await?; - Ok(()) -} - -async fn reload_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Reload(id.to_owned()))).await?; - Ok(()) -} - -async fn uninstall_plugin_async(id: &str) -> ResultType<()> { - let mut c = connect(1000, "").await?; - c.send(&Data::Plugin(Plugin::Uninstall(id.to_owned()))) - .await?; - Ok(()) -} - -pub async fn handle_plugin(plugin: Plugin, stream: &mut Connection) { - match plugin { - Plugin::Config(id, name, value) => match value { - None => { - let value = super::SharedConfig::get(&id, &name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::Config(id, name, value))) - .await - ); - } - Some(value) => { - allow_err!(super::SharedConfig::set(&id, &name, &value)); - } - }, - Plugin::ManagerConfig(name, value) => match value { - None => { - let value = super::ManagerConfig::get_option(&name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::ManagerConfig(name, value))) - .await - ); - } - Some(value) => { - super::ManagerConfig::set_option(&name, &value); - } - }, - Plugin::ManagerPluginConfig(id, name, value) => match value { - None => { - let value = super::ManagerConfig::get_plugin_option(&id, &name); - allow_err!( - stream - .send(&Data::Plugin(Plugin::ManagerPluginConfig(id, name, value))) - .await - ); - } - Some(value) => { - super::ManagerConfig::set_plugin_option(&id, &name, &value); - } - }, - Plugin::Load(id) => { - allow_err!(super::load_plugin(&id)); - } - Plugin::Reload(id) => { - allow_err!(super::reload_plugin(&id)); - } - Plugin::Uninstall(id) => { - super::manager::uninstall_plugin(&id, false); - } - _ => {} - } -} diff --git a/src/plugin/manager.rs b/src/plugin/manager.rs deleted file mode 100644 index f59e4c9ff78..00000000000 --- a/src/plugin/manager.rs +++ /dev/null @@ -1,600 +0,0 @@ -// 1. Check update. -// 2. Install or uninstall. - -use super::{desc::Meta as PluginMeta, ipc::InstallStatus, *}; -use crate::flutter; -use crate::hbbs_http::create_http_client; -use hbb_common::{allow_err, bail, log, tokio, toml}; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use std::{ - collections::{HashMap, HashSet}, - fs::{read_to_string, remove_dir_all, OpenOptions}, - io::Write, - sync::{Arc, Mutex}, -}; - -const MSG_TO_UI_PLUGIN_MANAGER_LIST: &str = "plugin_list"; -const MSG_TO_UI_PLUGIN_MANAGER_INSTALL: &str = "plugin_install"; -const MSG_TO_UI_PLUGIN_MANAGER_UNINSTALL: &str = "plugin_uninstall"; - -const IPC_PLUGIN_POSTFIX: &str = "_plugin"; - -#[cfg(target_os = "windows")] -const PLUGIN_PLATFORM: &str = "windows"; -#[cfg(target_os = "linux")] -const PLUGIN_PLATFORM: &str = "linux"; -#[cfg(target_os = "macos")] -const PLUGIN_PLATFORM: &str = "macos"; - -lazy_static::lazy_static! { - static ref PLUGIN_INFO: Arc>> = Arc::new(Mutex::new(HashMap::new())); -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct ManagerMeta { - pub version: String, - pub description: String, - pub plugins: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginSource { - pub name: String, - pub url: String, - pub description: String, -} - -#[derive(Debug, Serialize)] -pub struct PluginInfo { - pub source: PluginSource, - pub meta: PluginMeta, - pub installed_version: String, - pub invalid_reason: String, -} - -static PLUGIN_SOURCE_LOCAL: &str = "local"; - -fn get_plugin_source_list() -> Vec { - // Only one source for now. - // vec![PluginSource { - // name: "rustdesk".to_string(), - // url: "https://raw.githubusercontent.com/fufesou/rustdesk-plugins/main".to_string(), - // description: "".to_string(), - // }] - vec![] -} - -fn get_source_plugins() -> HashMap { - let mut plugins = HashMap::new(); - for source in get_plugin_source_list().into_iter() { - let url = format!("{}/meta.toml", source.url); - match create_http_client().get(&url).send() { - Ok(resp) => { - if !resp.status().is_success() { - log::error!( - "Failed to get plugin list from '{}', status code: {}", - url, - resp.status() - ); - } - if let Ok(text) = resp.text() { - match toml::from_str::(&text) { - Ok(manager_meta) => { - for meta in manager_meta.plugins.iter() { - if !meta - .platforms - .to_uppercase() - .contains(&PLUGIN_PLATFORM.to_uppercase()) - { - continue; - } - plugins.insert( - meta.id.clone(), - PluginInfo { - source: source.clone(), - meta: meta.clone(), - installed_version: "".to_string(), - invalid_reason: "".to_string(), - }, - ); - } - } - Err(e) => log::error!("Failed to parse plugin list from '{}', {}", url, e), - } - } - } - Err(e) => log::error!("Failed to get plugin list from '{}', {}", url, e), - } - } - plugins -} - -fn send_plugin_list_event(plugins: &HashMap) { - let mut plugin_list = plugins.values().collect::>(); - plugin_list.sort_by(|a, b| a.meta.name.cmp(&b.meta.name)); - if let Ok(plugin_list) = serde_json::to_string(&plugin_list) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_MANAGER); - m.insert(MSG_TO_UI_PLUGIN_MANAGER_LIST, &plugin_list); - if let Ok(event) = serde_json::to_string(&m) { - let _res = flutter::push_global_event(flutter::APP_TYPE_MAIN, event.clone()); - } - } -} - -pub fn load_plugin_list() { - let mut plugin_info_lock = PLUGIN_INFO.lock().unwrap(); - let mut plugins = get_source_plugins(); - - // A big read lock is needed to prevent race conditions. - // Loading plugin list may be slow. - // Users may call uninstall plugin in the middle. - let plugin_infos = super::plugins::get_plugin_infos(); - let plugin_infos_read_lock = plugin_infos.read().unwrap(); - for (id, info) in plugin_infos_read_lock.iter() { - if info.uninstalled { - continue; - } - - if let Some(p) = plugins.get_mut(id) { - p.installed_version = info.desc.meta().version.clone(); - p.invalid_reason = "".to_string(); - } else { - plugins.insert( - id.to_string(), - PluginInfo { - source: PluginSource { - name: PLUGIN_SOURCE_LOCAL.to_string(), - url: PLUGIN_SOURCE_LOCAL_DIR.to_string(), - description: "".to_string(), - }, - meta: info.desc.meta().clone(), - installed_version: info.desc.meta().version.clone(), - invalid_reason: "".to_string(), - }, - ); - } - } - send_plugin_list_event(&plugins); - *plugin_info_lock = plugins; -} - -#[cfg(target_os = "windows")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - // to-do: Support args with space in quotes. 'arg 1' and "arg 2" - let args = if same_plugin_exists { - format!("--plugin-install {}", plugin_id) - } else { - format!("--plugin-install {} {}", plugin_id, plugin_url) - }; - crate::platform::elevate(&args) -} - -#[cfg(target_os = "linux")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - let mut args = vec!["--plugin-install", plugin_id]; - if !same_plugin_exists { - args.push(&plugin_url); - } - crate::platform::elevate(args) -} - -#[cfg(target_os = "macos")] -fn elevate_install( - plugin_id: &str, - plugin_url: &str, - same_plugin_exists: bool, -) -> ResultType { - let mut args = vec!["--plugin-install", plugin_id]; - if !same_plugin_exists { - args.push(&plugin_url); - } - crate::platform::elevate(args, "RustDesk wants to install then plugin") -} - -#[inline] -#[cfg(target_os = "windows")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate(&format!("--plugin-uninstall {}", plugin_id)) -} - -#[inline] -#[cfg(target_os = "linux")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate(vec!["--plugin-uninstall", plugin_id]) -} - -#[inline] -#[cfg(target_os = "macos")] -fn elevate_uninstall(plugin_id: &str) -> ResultType { - crate::platform::elevate( - vec!["--plugin-uninstall", plugin_id], - "RustDesk wants to uninstall the plugin", - ) -} - -pub fn install_plugin(id: &str) -> ResultType<()> { - match PLUGIN_INFO.lock().unwrap().get(id) { - Some(plugin) => { - let mut same_plugin_exists = false; - if let Some(version) = super::plugins::get_version(id) { - if version == plugin.meta.version { - same_plugin_exists = true; - } - } - let plugin_url = format!( - "{}/plugins/{}/{}/{}_{}.zip", - plugin.source.url, - plugin.meta.id, - PLUGIN_PLATFORM, - plugin.meta.id, - plugin.meta.version - ); - let allowed_install = elevate_install(id, &plugin_url, same_plugin_exists)?; - if allowed_install && same_plugin_exists { - super::ipc::load_plugin(id)?; - super::plugins::load_plugin(id)?; - super::plugins::mark_uninstalled(id, false); - push_install_event(id, "finished"); - } - Ok(()) - } - None => { - bail!("Plugin not found: {}", id); - } - } -} - -fn get_uninstalled_plugins(uninstalled_plugin_set: &HashSet) -> ResultType> { - let plugins_dir = super::get_plugins_dir()?; - let mut plugins = Vec::new(); - if plugins_dir.exists() { - for entry in std::fs::read_dir(plugins_dir)? { - match entry { - Ok(entry) => { - let plugin_dir = entry.path(); - if plugin_dir.is_dir() { - if let Some(id) = plugin_dir.file_name().and_then(|n| n.to_str()) { - if uninstalled_plugin_set.contains(id) { - plugins.push(id.to_string()); - } - } - } - } - Err(e) => { - log::error!("Failed to read plugins dir entry, {}", e); - } - } - } - } - Ok(plugins) -} - -pub fn remove_uninstalled() -> ResultType<()> { - let mut uninstalled_plugin_set = get_uninstall_id_set()?; - for id in get_uninstalled_plugins(&uninstalled_plugin_set)?.iter() { - super::config::remove(id as _); - if let Ok(dir) = super::get_plugin_dir(id as _) { - allow_err!(remove_dir_all(dir.clone())); - if !dir.exists() { - uninstalled_plugin_set.remove(id); - } - } - } - allow_err!(update_uninstall_id_set(uninstalled_plugin_set)); - Ok(()) -} - -pub fn uninstall_plugin(id: &str, called_by_ui: bool) { - if called_by_ui { - match elevate_uninstall(id) { - Ok(true) => { - if let Err(e) = super::ipc::uninstall_plugin(id) { - log::error!("Failed to uninstall plugin '{}': {}", id, e); - push_uninstall_event(id, "failed"); - return; - } - super::plugins::unload_plugin(id); - super::plugins::mark_uninstalled(id, true); - super::config::remove(id); - push_uninstall_event(id, ""); - } - Ok(false) => { - return; - } - Err(e) => { - log::error!( - "Failed to uninstall plugin '{}', check permission error: {}", - id, - e - ); - push_uninstall_event(id, "failed"); - return; - } - } - } - - if super::is_server_running() { - super::plugins::unload_plugin(&id); - } -} - -fn push_event(id: &str, r#type: &str, msg: &str) { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_MANAGER); - m.insert("id", id); - m.insert(r#type, msg); - if let Ok(event) = serde_json::to_string(&m) { - let _res = flutter::push_global_event(flutter::APP_TYPE_MAIN, event.clone()); - } -} - -#[inline] -fn push_uninstall_event(id: &str, msg: &str) { - push_event(id, MSG_TO_UI_PLUGIN_MANAGER_UNINSTALL, msg); -} - -#[inline] -fn push_install_event(id: &str, msg: &str) { - push_event(id, MSG_TO_UI_PLUGIN_MANAGER_INSTALL, msg); -} - -async fn handle_conn(mut stream: crate::ipc::Connection) { - loop { - tokio::select! { - res = stream.next() => { - match res { - Err(err) => { - log::trace!("plugin ipc connection closed: {}", err); - break; - } - Ok(Some(data)) => { - match &data { - crate::ipc::Data::Plugin(super::ipc::Plugin::InstallStatus((id, status))) => { - match status { - InstallStatus::Downloading(n) => { - push_install_event(&id, &format!("downloading-{}", n)); - }, - InstallStatus::Installing => { - push_install_event(&id, "installing"); - } - InstallStatus::Finished => { - allow_err!(super::plugins::load_plugin(&id)); - allow_err!(super::ipc::load_plugin_async(id).await); - std::thread::spawn(load_plugin_list); - push_install_event(&id, "finished"); - } - InstallStatus::FailedCreating => { - push_install_event(&id, "failed-creating"); - } - InstallStatus::FailedDownloading => { - push_install_event(&id, "failed-downloading"); - } - InstallStatus::FailedInstalling => { - push_install_event(&id, "failed-installing"); - } - } - } - _ => {} - } - } - _ => { - } - } - } - } - } -} - -#[cfg(not(any(target_os = "android", target_os = "ios")))] -#[tokio::main] -pub async fn start_ipc() { - match crate::ipc::new_listener(IPC_PLUGIN_POSTFIX).await { - Ok(mut incoming) => { - while let Some(result) = incoming.next().await { - match result { - Ok(stream) => { - log::debug!("Got new connection"); - tokio::spawn(handle_conn(crate::ipc::Connection::new(stream))); - } - Err(err) => { - log::error!("Couldn't get plugin client: {:?}", err); - } - } - } - } - Err(err) => { - log::error!("Failed to start plugin ipc server: {}", err); - } - } -} - -pub(super) fn get_uninstall_id_set() -> ResultType> { - let uninstall_file_path = super::get_uninstall_file_path()?; - if !uninstall_file_path.exists() { - std::fs::create_dir_all(&super::get_plugins_dir()?)?; - return Ok(HashSet::new()); - } - let s = read_to_string(uninstall_file_path)?; - Ok(serde_json::from_str::>(&s)?) -} - -fn update_uninstall_id_set(set: HashSet) -> ResultType<()> { - let content = serde_json::to_string(&set)?; - let file = OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(super::get_uninstall_file_path()?)?; - let mut writer = std::io::BufWriter::new(file); - writer.write_all(content.as_bytes())?; - Ok(()) -} - -// install process -pub(super) mod install { - use super::IPC_PLUGIN_POSTFIX; - use crate::hbbs_http::create_http_client; - use crate::{ - ipc::{connect, Data}, - plugin::ipc::{InstallStatus, Plugin}, - }; - use hbb_common::{allow_err, bail, log, tokio, ResultType}; - use std::{ - fs::File, - io::{BufReader, BufWriter, Write}, - path::Path, - }; - use zip::ZipArchive; - - #[tokio::main(flavor = "current_thread")] - async fn send_install_status(id: &str, status: InstallStatus) { - allow_err!(_send_install_status(id, status).await); - } - - async fn _send_install_status(id: &str, status: InstallStatus) -> ResultType<()> { - let mut c = connect(1_000, IPC_PLUGIN_POSTFIX).await?; - c.send(&Data::Plugin(Plugin::InstallStatus(( - id.to_string(), - status, - )))) - .await?; - Ok(()) - } - - fn download_to_file(url: &str, file: File) -> ResultType<()> { - let resp = match create_http_client().get(url).send() { - Ok(resp) => resp, - Err(e) => { - bail!("get plugin from '{}', {}", url, e); - } - }; - - if !resp.status().is_success() { - bail!("get plugin from '{}', status code: {}", url, resp.status()); - } - - let mut writer = BufWriter::new(file); - writer.write_all(resp.bytes()?.as_ref())?; - Ok(()) - } - - fn download_file(id: &str, url: &str, filename: &Path) -> bool { - let file = match File::create(filename) { - Ok(f) => f, - Err(e) => { - log::error!("Failed to create plugin file: {}", e); - send_install_status(id, InstallStatus::FailedCreating); - return false; - } - }; - if let Err(e) = download_to_file(url, file) { - log::error!("Failed to download plugin '{}', {}", id, e); - send_install_status(id, InstallStatus::FailedDownloading); - return false; - } - true - } - - fn do_install_file(filename: &Path, target_dir: &Path) -> ResultType<()> { - let mut zip = ZipArchive::new(BufReader::new(File::open(filename)?))?; - for i in 0..zip.len() { - let mut file = zip.by_index(i)?; - let file_path = target_dir.join(file.name()); - if file.name().ends_with("/") { - std::fs::create_dir_all(&file_path)?; - } else { - if let Some(p) = file_path.parent() { - if !p.exists() { - std::fs::create_dir_all(&p)?; - } - } - let mut outfile = File::create(&file_path)?; - std::io::copy(&mut file, &mut outfile)?; - } - } - Ok(()) - } - - pub fn change_uninstall_plugin(id: &str, add: bool) { - match super::get_uninstall_id_set() { - Ok(mut set) => { - if add { - set.insert(id.to_string()); - } else { - set.remove(id); - } - if let Err(e) = super::update_uninstall_id_set(set) { - log::error!("Failed to write uninstall list, {}", e); - } - } - Err(e) => log::error!( - "Failed to get plugins dir, unable to read uninstall list, {}", - e - ), - } - } - - pub fn install_plugin_with_url(id: &str, url: &str) { - log::info!("Installing plugin '{}', url: {}", id, url); - let plugin_dir = match super::super::get_plugin_dir(id) { - Ok(d) => d, - Err(e) => { - send_install_status(id, InstallStatus::FailedCreating); - log::error!("Failed to get plugin dir: {}", e); - return; - } - }; - if !plugin_dir.exists() { - if let Err(e) = std::fs::create_dir_all(&plugin_dir) { - send_install_status(id, InstallStatus::FailedCreating); - log::error!("Failed to create plugin dir: {}", e); - return; - } - } - - let filename = match url.rsplit('/').next() { - Some(filename) => plugin_dir.join(filename), - None => { - send_install_status(id, InstallStatus::FailedDownloading); - log::error!("Failed to download plugin file, invalid url: {}", url); - return; - } - }; - - let filename_to_remove = filename.clone(); - let _call_on_ret = crate::common::SimpleCallOnReturn { - b: true, - f: Box::new(move || { - if let Err(e) = std::fs::remove_file(&filename_to_remove) { - log::error!("Failed to remove plugin file: {}", e); - } - }), - }; - - // download - if !download_file(id, url, &filename) { - return; - } - - // install - send_install_status(id, InstallStatus::Installing); - if let Err(e) = do_install_file(&filename, &plugin_dir) { - log::error!("Failed to install plugin: {}", e); - send_install_status(id, InstallStatus::FailedInstalling); - return; - } - - // finished - send_install_status(id, InstallStatus::Finished); - } -} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs deleted file mode 100644 index bd4b21b678e..00000000000 --- a/src/plugin/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -use hbb_common::{bail, libc, log, ResultType}; -#[cfg(target_os = "windows")] -use std::env; -use std::{ - ffi::{c_char, c_int, c_void, CStr}, - path::PathBuf, - ptr::null, -}; - -mod callback_ext; -mod callback_msg; -mod config; -pub mod desc; -mod errno; -pub mod ipc; -mod manager; -pub mod native; -pub mod native_handlers; -mod plog; -mod plugins; - -pub use manager::{ - install::{change_uninstall_plugin, install_plugin_with_url}, - install_plugin, load_plugin_list, remove_uninstalled, uninstall_plugin, -}; -pub use plugins::{ - handle_client_event, handle_listen_event, handle_server_event, handle_ui_event, load_plugin, - reload_plugin, sync_ui, unload_plugin, -}; - -const MSG_TO_UI_TYPE_PLUGIN_EVENT: &str = "plugin_event"; -const MSG_TO_UI_TYPE_PLUGIN_RELOAD: &str = "plugin_reload"; -const MSG_TO_UI_TYPE_PLUGIN_OPTION: &str = "plugin_option"; -const MSG_TO_UI_TYPE_PLUGIN_MANAGER: &str = "plugin_manager"; - -pub const EVENT_ON_CONN_CLIENT: &str = "on_conn_client"; -pub const EVENT_ON_CONN_SERVER: &str = "on_conn_server"; -pub const EVENT_ON_CONN_CLOSE_CLIENT: &str = "on_conn_close_client"; -pub const EVENT_ON_CONN_CLOSE_SERVER: &str = "on_conn_close_server"; - -static PLUGIN_SOURCE_LOCAL_DIR: &str = "plugins"; - -pub use config::{ManagerConfig, PeerConfig, SharedConfig}; - -/// Common plugin return. -/// -/// [Note] -/// The msg must be nullptr if code is errno::ERR_SUCCESS. -/// The msg must be freed by caller if code is not errno::ERR_SUCCESS. -#[repr(C)] -#[derive(Debug)] -pub struct PluginReturn { - pub code: c_int, - pub msg: *const c_char, -} - -impl PluginReturn { - pub fn success() -> Self { - Self { - code: errno::ERR_SUCCESS, - msg: null(), - } - } - - #[inline] - pub fn is_success(&self) -> bool { - self.code == errno::ERR_SUCCESS - } - - pub fn new(code: c_int, msg: &str) -> Self { - Self { - code, - msg: str_to_cstr_ret(msg), - } - } - - pub fn get_code_msg(&mut self, id: &str) -> (i32, String) { - if self.is_success() { - (self.code, "".to_owned()) - } else { - if self.msg.is_null() { - log::warn!( - "The message pointer from the plugin '{}' is null, but the error code is {}", - id, - self.code - ); - return (self.code, "".to_owned()); - } - let msg = cstr_to_string(self.msg).unwrap_or_default(); - free_c_ptr(self.msg as _); - self.msg = null(); - (self.code as _, msg) - } - } -} - -fn is_server_running() -> bool { - crate::common::is_server() || crate::common::is_server_running() -} - -pub fn init() { - if !is_server_running() { - std::thread::spawn(move || manager::start_ipc()); - } else { - if let Err(e) = remove_uninstalled() { - log::error!("Failed to remove plugins: {}", e); - } - } - match manager::get_uninstall_id_set() { - Ok(ids) => { - if let Err(e) = plugins::load_plugins(&ids) { - log::error!("Failed to load plugins: {}", e); - } - } - Err(e) => { - log::error!("Failed to load plugins: {}", e); - } - } -} - -#[inline] -#[cfg(target_os = "windows")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from(env::var("ProgramData")?)) -} - -#[inline] -#[cfg(target_os = "linux")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from("/usr/share")) -} - -#[inline] -#[cfg(target_os = "macos")] -fn get_share_dir() -> ResultType { - Ok(PathBuf::from("/Library/Application Support")) -} - -#[inline] -fn get_plugins_dir() -> ResultType { - Ok(get_share_dir()? - .join("RustDesk") - .join(PLUGIN_SOURCE_LOCAL_DIR)) -} - -#[inline] -fn get_plugin_dir(id: &str) -> ResultType { - Ok(get_plugins_dir()?.join(id)) -} - -#[inline] -fn get_uninstall_file_path() -> ResultType { - Ok(get_plugins_dir()?.join("uninstall_list")) -} - -#[inline] -fn cstr_to_string(cstr: *const c_char) -> ResultType { - if cstr.is_null() { - bail!("failed to convert string, the pointer is null"); - } - Ok(String::from_utf8(unsafe { - CStr::from_ptr(cstr).to_bytes().to_vec() - })?) -} - -#[inline] -fn str_to_cstr_ret(s: &str) -> *const c_char { - let mut s = s.as_bytes().to_vec(); - s.push(0); - unsafe { - let r = libc::malloc(s.len()) as *mut c_char; - libc::memcpy( - r as *mut libc::c_void, - s.as_ptr() as *const libc::c_void, - s.len(), - ); - r - } -} - -#[inline] -fn free_c_ptr(p: *mut c_void) { - if !p.is_null() { - unsafe { - libc::free(p); - } - } -} diff --git a/src/plugin/native.rs b/src/plugin/native.rs deleted file mode 100644 index ce885c77c8a..00000000000 --- a/src/plugin/native.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{ - ffi::{c_char, c_int, c_void}, - os::raw::c_uint, -}; - -use hbb_common::log::error; - -use super::{ - cstr_to_string, - errno::ERR_NOT_HANDLED, - native_handlers::{Callable, NATIVE_HANDLERS_REGISTRAR}, -}; -/// The native returned value from librustdesk native. -/// -/// [Note] -/// The data is owned by librustdesk. -#[repr(C)] -pub struct NativeReturnValue { - pub return_type: c_int, - pub data: *const c_void, -} - -pub(super) extern "C" fn cb_native_data( - method: *const c_char, - json: *const c_char, - raw: *const c_void, - raw_len: usize, -) -> NativeReturnValue { - let ret = match cstr_to_string(method) { - Ok(method) => NATIVE_HANDLERS_REGISTRAR.call(&method, json, raw, raw_len), - Err(err) => { - error!("cb_native_data error: {}", err); - None - } - }; - return ret.unwrap_or(NativeReturnValue { - return_type: ERR_NOT_HANDLED, - data: std::ptr::null(), - }); -} diff --git a/src/plugin/native_handlers/macros.rs b/src/plugin/native_handlers/macros.rs deleted file mode 100644 index 82d7e10a6e4..00000000000 --- a/src/plugin/native_handlers/macros.rs +++ /dev/null @@ -1,27 +0,0 @@ -#[macro_export] -macro_rules! return_if_not_method { - ($call: ident, $prefix: ident) => { - if $call.starts_with($prefix) { - return None; - } - }; -} - -#[macro_export] -macro_rules! call_if_method { - ($call: ident ,$method: literal, $block: block) => { - if ($call != $method) { - $block - } - }; -} - -#[macro_export] -macro_rules! define_method_prefix { - ($prefix: literal) => { - #[inline] - fn method_prefix(&self) -> &'static str { - $prefix - } - }; -} diff --git a/src/plugin/native_handlers/mod.rs b/src/plugin/native_handlers/mod.rs deleted file mode 100644 index 7d590ab1e5b..00000000000 --- a/src/plugin/native_handlers/mod.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::{ - ffi::c_void, - sync::{Arc, RwLock}, - vec, -}; - -use hbb_common::libc::c_char; -use lazy_static::lazy_static; -use serde_json::Map; - -use crate::return_if_not_method; - -use self::{session::PluginNativeSessionHandler, ui::PluginNativeUIHandler}; - -use super::cstr_to_string; - -mod macros; -pub mod session; -pub mod ui; - -pub type NR = super::native::NativeReturnValue; -pub type PluginNativeHandlerRegistrar = NativeHandlerRegistrar>; - -lazy_static! { - pub static ref NATIVE_HANDLERS_REGISTRAR: Arc = - Arc::new(PluginNativeHandlerRegistrar::default()); -} - -#[derive(Clone)] -pub struct NativeHandlerRegistrar { - handlers: Arc>>, -} - -impl Default for PluginNativeHandlerRegistrar { - fn default() -> Self { - Self { - handlers: Arc::new(RwLock::new(vec![ - // Add prebuilt native handlers here. - Box::new(PluginNativeSessionHandler::default()), - Box::new(PluginNativeUIHandler::default()), - ])), - } - } -} - -pub(self) trait PluginNativeHandler { - /// The method prefix handled by this handler.s - fn method_prefix(&self) -> &'static str; - - /// Try to handle the method with the given data. - /// - /// Returns: None for the message does not be handled by this handler. - fn on_message(&self, method: &str, data: &Map) -> Option; - - /// Try to handle the method with the given data and extra void binary data. - /// - /// Returns: None for the message does not be handled by this handler. - fn on_message_raw( - &self, - method: &str, - data: &Map, - raw: *const c_void, - raw_len: usize, - ) -> Option; -} - -pub trait Callable { - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - None - } -} - -impl Callable for T -where - T: PluginNativeHandler + Send + Sync, -{ - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - let prefix = self.method_prefix(); - return_if_not_method!(method, prefix); - match cstr_to_string(json) { - Ok(s) => { - if let Ok(json) = serde_json::from_str(s.as_str()) { - let method_suffix = &method[prefix.len()..]; - if raw != std::ptr::null() && raw_len > 0 { - return self.on_message_raw(method_suffix, &json, raw, raw_len); - } else { - return self.on_message(method_suffix, &json); - } - } else { - return None; - } - } - Err(_) => return None, - } - } -} - -impl Callable for PluginNativeHandlerRegistrar { - fn call( - &self, - method: &String, - json: *const c_char, - raw: *const c_void, - raw_len: usize, - ) -> Option { - for handler in self.handlers.read().unwrap().iter() { - let ret = handler.call(method, json, raw, raw_len); - if ret.is_some() { - return ret; - } - } - None - } -} diff --git a/src/plugin/native_handlers/session.rs b/src/plugin/native_handlers/session.rs deleted file mode 100644 index 3a3f62f8dc3..00000000000 --- a/src/plugin/native_handlers/session.rs +++ /dev/null @@ -1,219 +0,0 @@ -use std::{ - collections::HashMap, - ffi::{c_char, c_void}, - ptr::addr_of_mut, - sync::{Arc, RwLock}, -}; - -use flutter_rust_bridge::StreamSink; - -use crate::{define_method_prefix, flutter_ffi::EventToUI}; - -const MSG_TO_UI_TYPE_SESSION_CREATED: &str = "session_created"; - -use super::PluginNativeHandler; - -pub type OnSessionRgbaCallback = unsafe extern "C" fn( - *const c_char, // Session ID - *mut c_void, // raw data - *mut usize, // width - *mut usize, // height, - *mut usize, // stride, - *mut scrap::ImageFormat, // ImageFormat -); - -#[derive(Default)] -/// Session related handler for librustdesk core. -pub struct PluginNativeSessionHandler { - sessions: Arc>>, - cbs: Arc>>, -} - -lazy_static::lazy_static! { - pub static ref SESSION_HANDLER: Arc = Arc::new(PluginNativeSessionHandler::default()); -} - -impl PluginNativeHandler for PluginNativeSessionHandler { - define_method_prefix!("session_"); - - fn on_message( - &self, - method: &str, - data: &serde_json::Map, - ) -> Option { - match method { - "create_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - return Some(super::NR { - return_type: 1, - data: SESSION_HANDLER.create_session(id.to_string()).as_ptr() as _, - }); - } - } - } - "start_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - let sessions = SESSION_HANDLER.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == id { - let round = - session.connection_round_state.lock().unwrap().new_round(); - crate::ui_session_interface::io_loop(session.clone(), round); - } - } - } - } - } - "remove_session_hook" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - SESSION_HANDLER.remove_session_hook(id.to_string()); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - "remove_session" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - SESSION_HANDLER.remove_session(id.to_owned()); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - _ => {} - } - None - } - - fn on_message_raw( - &self, - method: &str, - data: &serde_json::Map, - raw: *const std::ffi::c_void, - _raw_len: usize, - ) -> Option { - match method { - "add_session_hook" => { - if let Some(id) = data.get("id") { - if let Some(id) = id.as_str() { - let cb: OnSessionRgbaCallback = unsafe { std::mem::transmute(raw) }; - SESSION_HANDLER.add_session_hook(id.to_string(), cb); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - } - _ => {} - } - None - } -} - -impl PluginNativeSessionHandler { - fn create_session(&self, session_id: String) -> String { - let session = - crate::flutter::session_add(&session_id, false, false, false, "", false, "".to_owned()); - if let Ok(session) = session { - let mut sessions = self.sessions.write().unwrap(); - sessions.push(session); - // push a event to notify flutter to bind a event stream for this session. - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_SESSION_CREATED); - m.insert("session_id", &session_id); - // todo: APP_TYPE_DESKTOP_REMOTE is not used anymore. - // crate::flutter::APP_TYPE_DESKTOP_REMOTE + window id, is used for multi-window support. - crate::flutter::push_global_event( - crate::flutter::APP_TYPE_DESKTOP_REMOTE, - serde_json::to_string(&m).unwrap_or("".to_string()), - ); - return session_id; - } else { - return "".to_string(); - } - } - - fn add_session_hook(&self, session_id: String, cb: OnSessionRgbaCallback) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - self.cbs.write().unwrap().insert(session_id.to_owned(), cb); - session.ui_handler.add_session_hook( - session_id, - crate::flutter::SessionHook::OnSessionRgba(session_rgba_cb), - ); - break; - } - } - } - - fn remove_session_hook(&self, session_id: String) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - session.ui_handler.remove_session_hook(&session_id); - } - } - } - - fn remove_session(&self, session_id: String) { - let _ = self.cbs.write().unwrap().remove(&session_id); - let mut sessions = self.sessions.write().unwrap(); - for i in 0..sessions.len() { - if sessions[i].id == session_id { - sessions[i].close_event_stream(); - sessions[i].close(); - sessions.remove(i); - } - } - } - - #[inline] - // The callback function for rgba data - fn session_rgba_cb(&self, session_id: String, rgb: &mut scrap::ImageRgb) { - let cbs = self.cbs.read().unwrap(); - if let Some(cb) = cbs.get(&session_id) { - unsafe { - cb( - session_id.as_ptr() as _, - rgb.raw.as_mut_ptr() as _, - addr_of_mut!(rgb.w), - addr_of_mut!(rgb.h), - addr_of_mut!(rgb.stride), - addr_of_mut!(rgb.fmt), - ); - } - } - } - - #[inline] - // The callback function for rgba data - fn session_register_event_stream(&self, session_id: String, stream: StreamSink) { - let sessions = self.sessions.read().unwrap(); - for session in sessions.iter() { - if session.id == session_id { - *session.event_stream.write().unwrap() = Some(stream); - break; - } - } - } -} - -#[inline] -fn session_rgba_cb(id: String, rgb: &mut scrap::ImageRgb) { - SESSION_HANDLER.session_rgba_cb(id, rgb); -} - -#[inline] -pub fn session_register_event_stream(id: String, stream: StreamSink) { - SESSION_HANDLER.session_register_event_stream(id, stream); -} diff --git a/src/plugin/native_handlers/ui.rs b/src/plugin/native_handlers/ui.rs deleted file mode 100644 index aec7facd868..00000000000 --- a/src/plugin/native_handlers/ui.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::{collections::HashMap, ffi::c_void, os::raw::c_int}; - -use serde_json::json; - -use crate::{define_method_prefix, flutter::APP_TYPE_MAIN}; - -use super::PluginNativeHandler; - -#[derive(Default)] -pub struct PluginNativeUIHandler; - -/// Callback for UI interface. -/// -/// [Note] -/// We will transfer the native callback to u64 and post it to flutter. -/// The flutter thread will directly call this method. -/// -/// an example of `data` is: -/// ``` -/// { -/// "cb": 0x1234567890 -/// } -/// ``` -/// [Safety] -/// Please make sure the callback u provided is VALID, or memory or calling issues may occur to cause the program crash! -pub type OnUIReturnCallback = - extern "C" fn(return_code: c_int, data: *const c_void, data_len: u64, user_data: *const c_void); - -impl PluginNativeHandler for PluginNativeUIHandler { - define_method_prefix!("ui_"); - - fn on_message( - &self, - method: &str, - data: &serde_json::Map, - ) -> Option { - match method { - "select_peers_async" => { - if let Some(cb) = data.get("cb") { - if let Some(cb) = cb.as_u64() { - let user_data = match data.get("user_data") { - Some(user_data) => user_data.as_u64().unwrap_or(0), - None => 0, - }; - self.select_peers_async(cb, user_data); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - return Some(super::NR { - return_type: -1, - data: "missing cb field message".as_ptr() as _, - }); - } - "register_ui_entry" => { - let title; - if let Some(v) = data.get("title") { - title = v.as_str().unwrap_or(""); - } else { - title = ""; - } - if let Some(on_tap_cb) = data.get("on_tap_cb") { - if let Some(on_tap_cb) = on_tap_cb.as_u64() { - let user_data = match data.get("user_data") { - Some(user_data) => user_data.as_u64().unwrap_or(0), - None => 0, - }; - self.register_ui_entry(title, on_tap_cb, user_data); - return Some(super::NR { - return_type: 0, - data: std::ptr::null(), - }); - } - } - return Some(super::NR { - return_type: -1, - data: "missing cb field message".as_ptr() as _, - }); - } - _ => {} - } - None - } - - fn on_message_raw( - &self, - method: &str, - data: &serde_json::Map, - raw: *const std::ffi::c_void, - _raw_len: usize, - ) -> Option { - None - } -} - -impl PluginNativeUIHandler { - /// Call with method `select_peers_async` and the following json: - /// ```json - /// { - /// "cb": 0, // The function address - /// "user_data": 0 // An opaque pointer value passed to the callback. - /// } - /// ``` - /// - /// [Arguments] - /// @param cb: the function address with type [OnUIReturnCallback]. - /// @param user_data: the function will be called with this value. - fn select_peers_async(&self, cb: u64, user_data: u64) { - let mut param = HashMap::new(); - param.insert("name", json!("native_ui")); - param.insert("action", json!("select_peers")); - param.insert("cb", json!(cb)); - param.insert("user_data", json!(user_data)); - crate::flutter::push_global_event( - APP_TYPE_MAIN, - serde_json::to_string(¶m).unwrap_or("".to_string()), - ); - } - - /// Call with method `register_ui_entry` and the following json: - /// ``` - /// { - /// - /// "on_tap_cb": 0, // The function address - /// "user_data": 0, // An opaque pointer value passed to the callback. - /// "title": "entry name" - /// } - /// ``` - fn register_ui_entry(&self, title: &str, on_tap_cb: u64, user_data: u64) { - let mut param = HashMap::new(); - param.insert("name", json!("native_ui")); - param.insert("action", json!("register_ui_entry")); - param.insert("title", json!(title)); - param.insert("cb", json!(on_tap_cb)); - param.insert("user_data", json!(user_data)); - crate::flutter::push_global_event( - APP_TYPE_MAIN, - serde_json::to_string(¶m).unwrap_or("".to_string()), - ); - } -} diff --git a/src/plugin/plog.rs b/src/plugin/plog.rs deleted file mode 100644 index f1e78d36e39..00000000000 --- a/src/plugin/plog.rs +++ /dev/null @@ -1,34 +0,0 @@ -use hbb_common::log; -use std::ffi::c_char; - -const LOG_LEVEL_TRACE: &[u8; 6] = b"trace\0"; -const LOG_LEVEL_DEBUG: &[u8; 6] = b"debug\0"; -const LOG_LEVEL_INFO: &[u8; 5] = b"info\0"; -const LOG_LEVEL_WARN: &[u8; 5] = b"warn\0"; -const LOG_LEVEL_ERROR: &[u8; 6] = b"error\0"; - -#[inline] -fn is_level(level: *const c_char, level_bytes: &[u8]) -> bool { - level_bytes == unsafe { std::slice::from_raw_parts(level as *const u8, level_bytes.len()) } -} - -#[no_mangle] -pub(super) extern "C" fn plugin_log(level: *const c_char, msg: *const c_char) { - if level.is_null() || msg.is_null() { - return; - } - - if let Ok(msg) = super::cstr_to_string(msg) { - if is_level(level, LOG_LEVEL_TRACE) { - log::trace!("{}", msg); - } else if is_level(level, LOG_LEVEL_DEBUG) { - log::debug!("{}", msg); - } else if is_level(level, LOG_LEVEL_INFO) { - log::info!("{}", msg); - } else if is_level(level, LOG_LEVEL_WARN) { - log::warn!("{}", msg); - } else if is_level(level, LOG_LEVEL_ERROR) { - log::error!("{}", msg); - } - } -} diff --git a/src/plugin/plugins.rs b/src/plugin/plugins.rs deleted file mode 100644 index bf980ee8ca5..00000000000 --- a/src/plugin/plugins.rs +++ /dev/null @@ -1,659 +0,0 @@ -use super::{desc::Desc, errno::*, *}; -#[cfg(not(debug_assertions))] -use crate::common::is_server; -use crate::flutter; -use hbb_common::{ - bail, - dlopen::symbor::Library, - lazy_static, log, - message_proto::{Message, Misc, PluginFailure, PluginRequest}, - ResultType, -}; -use serde_derive::Serialize; -use std::{ - collections::{HashMap, HashSet}, - ffi::{c_char, c_void}, - path::Path, - sync::{Arc, RwLock}, -}; - -pub const METHOD_HANDLE_STATUS: &[u8; 14] = b"handle_status\0"; -pub const METHOD_HANDLE_SIGNATURE_VERIFICATION: &[u8; 30] = b"handle_signature_verification\0"; -const METHOD_HANDLE_UI: &[u8; 10] = b"handle_ui\0"; -const METHOD_HANDLE_PEER: &[u8; 12] = b"handle_peer\0"; -pub const METHOD_HANDLE_LISTEN_EVENT: &[u8; 20] = b"handle_listen_event\0"; - -lazy_static::lazy_static! { - static ref PLUGIN_INFO: Arc>> = Default::default(); - static ref PLUGINS: Arc>> = Default::default(); -} - -pub(super) struct PluginInfo { - pub path: String, - pub uninstalled: bool, - pub desc: Desc, -} - -/// Initialize the plugins. -/// -/// data: The initialize data. -type PluginFuncInit = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Reset the plugin. -/// -/// data: The initialize data. -type PluginFuncReset = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Clear the plugin. -type PluginFuncClear = extern "C" fn() -> PluginReturn; -/// Get the description of the plugin. -/// Return the description. The plugin allocate memory with `libc::malloc` and return the pointer. -type PluginFuncDesc = extern "C" fn() -> *const c_char; -/// Callback to send message to peer or ui. -/// peer, target, id are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// target: "peer" or "ui". -/// id: The id of this plugin. -/// content: The content. -/// len: The length of the content. -type CallbackMsg = extern "C" fn( - peer: *const c_char, - target: *const c_char, - id: *const c_char, - content: *const c_void, - len: usize, -) -> PluginReturn; -/// Callback to get the config. -/// peer, key are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// id: The id of this plugin. -/// key: The key of the config. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetConf = - extern "C" fn(peer: *const c_char, id: *const c_char, key: *const c_char) -> *const c_char; -/// Get local peer id. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetId = extern "C" fn() -> *const c_char; -/// Callback to log. -/// -/// level, msg are utf8 strings(null terminated). -/// level: "error", "warn", "info", "debug", "trace". -/// msg: The message. -type CallbackLog = extern "C" fn(level: *const c_char, msg: *const c_char); - -/// Callback to the librustdesk core. -/// -/// method: the method name of this callback. -/// json: the json data for the parameters. The argument *must* be non-null. -/// raw: the binary data for this call, nullable. -/// raw_len: the length of this binary data, only valid when we pass raw data to `raw`. -type CallbackNative = extern "C" fn( - method: *const c_char, - json: *const c_char, - raw: *const c_void, - raw_len: usize, -) -> super::native::NativeReturnValue; -/// The main function of the plugin. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -type PluginFuncCall = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, -) -> PluginReturn; -/// The main function of the plugin. -/// This function is called mainly for handling messages from the peer, -/// and then send messages back to the peer. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -/// out: The output. -/// The plugin allocate memory with `libc::malloc` and return the pointer. -/// out_len: The length of the output. -type PluginFuncCallWithOutData = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, - out: *mut *mut c_void, - out_len: *mut usize, -) -> PluginReturn; - -/// The plugin callbacks. -/// msg: The callback to send message to peer or ui. -/// get_conf: The callback to get the config. -/// log: The callback to log. -#[repr(C)] -#[derive(Copy, Clone)] -struct Callbacks { - msg: CallbackMsg, - get_conf: CallbackGetConf, - get_id: CallbackGetId, - log: CallbackLog, - native: CallbackNative, -} - -#[derive(Serialize)] -#[repr(C)] -struct InitInfo { - is_server: bool, -} - -/// The plugin initialize data. -/// version: The version of the plugin, can't be nullptr. -/// local_peer_id: The local peer id, can't be nullptr. -/// cbs: The callbacks. -#[repr(C)] -struct InitData { - version: *const c_char, - info: *const c_char, - cbs: Callbacks, -} - -impl Drop for InitData { - fn drop(&mut self) { - free_c_ptr(self.version as _); - free_c_ptr(self.info as _); - } -} - -macro_rules! make_plugin { - ($($field:ident : $tp:ty),+) => { - #[allow(dead_code)] - pub struct Plugin { - _lib: Library, - id: Option, - path: String, - $($field: $tp),+ - } - - impl Plugin { - fn new(path: &str) -> ResultType { - let lib = match Library::open(path) { - Ok(lib) => lib, - Err(e) => { - bail!("Failed to load library {}, {}", path, e); - } - }; - - $(let $field = match unsafe { lib.symbol::<$tp>(stringify!($field)) } { - Ok(m) => { - *m - }, - Err(e) => { - bail!("Failed to load {} func {}, {}", path, stringify!($field), e); - } - } - ;)+ - - Ok(Self { - _lib: lib, - id: None, - path: path.to_string(), - $( $field ),+ - }) - } - - fn desc(&self) -> ResultType { - let desc_ret = (self.desc)(); - let desc = Desc::from_cstr(desc_ret); - free_c_ptr(desc_ret as _); - desc - } - - fn init(&self, data: &InitData, path: &str) -> ResultType<()> { - let mut init_ret = (self.init)(data as _); - if !init_ret.is_success() { - let (code, msg) = init_ret.get_code_msg(path); - bail!( - "Failed to init plugin {}, code: {}, msg: {}", - path, - code, - msg - ); - } - Ok(()) - } - - fn clear(&self, id: &str) { - let mut clear_ret = (self.clear)(); - if !clear_ret.is_success() { - let (code, msg) = clear_ret.get_code_msg(id); - log::error!( - "Failed to clear plugin {}, code: {}, msg: {}", - id, - code, - msg - ); - } - } - } - - impl Drop for Plugin { - fn drop(&mut self) { - let id = self.id.as_ref().unwrap_or(&self.path); - self.clear(id); - } - } - } -} - -make_plugin!( - init: PluginFuncInit, - reset: PluginFuncReset, - clear: PluginFuncClear, - desc: PluginFuncDesc, - call: PluginFuncCall, - call_with_out_data: PluginFuncCallWithOutData -); - -#[derive(Serialize)] -pub struct MsgListenEvent { - pub event: String, -} - -#[cfg(target_os = "windows")] -const DYLIB_SUFFIX: &str = ".dll"; -#[cfg(target_os = "linux")] -const DYLIB_SUFFIX: &str = ".so"; -#[cfg(target_os = "macos")] -const DYLIB_SUFFIX: &str = ".dylib"; - -pub(super) fn load_plugins(uninstalled_ids: &HashSet) -> ResultType<()> { - let plugins_dir = super::get_plugins_dir()?; - if !plugins_dir.exists() { - std::fs::create_dir_all(&plugins_dir)?; - } else { - for entry in std::fs::read_dir(plugins_dir)? { - match entry { - Ok(entry) => { - let plugin_dir = entry.path(); - if plugin_dir.is_dir() { - if let Some(plugin_id) = plugin_dir.file_name().and_then(|f| f.to_str()) { - if uninstalled_ids.contains(plugin_id) { - log::debug!( - "Ignore loading '{}' as it should be uninstalled", - plugin_id - ); - continue; - } - load_plugin_dir(&plugin_dir); - } - } - } - Err(e) => { - log::error!("Failed to read plugins dir entry, {}", e); - } - } - } - } - Ok(()) -} - -fn load_plugin_dir(dir: &Path) { - log::debug!("Begin load plugin dir: {}", dir.display()); - if let Ok(rd) = std::fs::read_dir(dir) { - for entry in rd { - match entry { - Ok(entry) => { - let path = entry.path(); - if path.is_file() { - let filename = entry.file_name(); - let filename = filename.to_str().unwrap_or(""); - if filename.starts_with("plugin_") && filename.ends_with(DYLIB_SUFFIX) { - if let Some(path) = path.to_str() { - if let Err(e) = load_plugin_path(path) { - log::error!("Failed to load plugin {}, {}", filename, e); - } - } - } - } - } - Err(e) => { - log::error!( - "Failed to read '{}' dir entry, {}", - dir.file_name().and_then(|f| f.to_str()).unwrap_or(""), - e - ); - } - } - } - } -} - -pub fn unload_plugin(id: &str) { - log::info!("Plugin {} unloaded", id); - PLUGINS.write().unwrap().remove(id); -} - -pub(super) fn mark_uninstalled(id: &str, uninstalled: bool) { - log::info!("Plugin {} uninstall", id); - PLUGIN_INFO - .write() - .unwrap() - .get_mut(id) - .map(|info| info.uninstalled = uninstalled); -} - -pub fn reload_plugin(id: &str) -> ResultType<()> { - let path = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => plugin.path.clone(), - None => bail!("Plugin {} not found", id), - }; - unload_plugin(id); - load_plugin_path(&path) -} - -fn load_plugin_path(path: &str) -> ResultType<()> { - log::info!("Begin load plugin {}", path); - - let plugin = Plugin::new(path)?; - let desc = plugin.desc()?; - - // to-do validate plugin - // to-do check the plugin id (make sure it does not use another plugin's id) - - let id = desc.meta().id.clone(); - let plugin_info = PluginInfo { - path: path.to_string(), - uninstalled: false, - desc: desc.clone(), - }; - PLUGIN_INFO.write().unwrap().insert(id.clone(), plugin_info); - - let init_info = serde_json::to_string(&InitInfo { - is_server: super::is_server_running(), - })?; - let init_data = InitData { - version: str_to_cstr_ret(crate::VERSION), - info: str_to_cstr_ret(&init_info) as _, - cbs: Callbacks { - msg: callback_msg::cb_msg, - get_conf: config::cb_get_conf, - get_id: config::cb_get_local_peer_id, - log: super::plog::plugin_log, - native: super::native::cb_native_data, - }, - }; - // If do not load the plugin when init failed, the ui will not show the installed plugin. - if let Err(e) = plugin.init(&init_data, path) { - log::error!("Failed to init plugin '{}', {}", desc.meta().id, e); - } - - if super::is_server_running() { - super::config::ManagerConfig::add_plugin(&desc.meta().id)?; - } - - // update ui - // Ui may be not ready now, so we need to update again once ui is ready. - reload_ui(&desc, None); - - // add plugins - PLUGINS.write().unwrap().insert(id.clone(), plugin); - - log::info!("Plugin {} loaded, {}", id, path); - Ok(()) -} - -pub fn sync_ui(sync_to: String) { - for plugin in PLUGIN_INFO.read().unwrap().values() { - reload_ui(&plugin.desc, Some(&sync_to)); - } -} - -#[inline] -pub fn load_plugin(id: &str) -> ResultType<()> { - load_plugin_dir(&super::get_plugin_dir(id)?); - Ok(()) -} - -#[inline] -fn handle_event(method: &[u8], id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - plugin_call(id, method, &peer, event) -} - -pub fn plugin_call(id: &str, method: &[u8], peer: &str, event: &[u8]) -> ResultType<()> { - let mut ret = plugin_call_get_return(id, method, peer, event)?; - if ret.is_success() { - Ok(()) - } else { - let (code, msg) = ret.get_code_msg(id); - bail!( - "Failed to handle plugin event, id: {}, method: {}, code: {}, msg: {}", - id, - std::string::String::from_utf8(method.to_vec()).unwrap_or_default(), - code, - msg - ); - } -} - -#[inline] -pub fn plugin_call_get_return( - id: &str, - method: &[u8], - peer: &str, - event: &[u8], -) -> ResultType { - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => Ok((plugin.call)( - method.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - )), - None => bail!("Plugin {} not found", id), - } -} - -#[inline] -pub fn handle_ui_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_UI, id, peer, event) -} - -#[inline] -pub fn handle_server_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_PEER, id, peer, event) -} - -fn _handle_listen_event(event: String, peer: String) { - let mut plugins = Vec::new(); - for info in PLUGIN_INFO.read().unwrap().values() { - if info.desc.listen_events().contains(&event.to_string()) { - plugins.push(info.desc.meta().id.clone()); - } - } - - if plugins.is_empty() { - return; - } - - if let Ok(evt) = serde_json::to_string(&MsgListenEvent { - event: event.clone(), - }) { - let mut evt_bytes = evt.as_bytes().to_vec(); - evt_bytes.push(0); - let mut peer: String = peer.to_owned(); - peer.push('\0'); - for id in plugins { - match PLUGINS.read().unwrap().get(&id) { - Some(plugin) => { - let mut ret = (plugin.call)( - METHOD_HANDLE_LISTEN_EVENT.as_ptr() as _, - peer.as_ptr() as _, - evt_bytes.as_ptr() as _, - evt_bytes.len(), - ); - if !ret.is_success() { - let (code, msg) = ret.get_code_msg(&id); - log::error!( - "Failed to handle plugin listen event, id: {}, event: {}, code: {}, msg: {}", - id, - event, - code, - msg - ); - } - } - None => { - log::error!("Plugin {} not found when handle_listen_event", id); - } - } - } - } -} - -#[inline] -pub fn handle_listen_event(event: String, peer: String) { - std::thread::spawn(|| _handle_listen_event(event, peer)); -} - -#[inline] -pub fn handle_client_event(id: &str, peer: &str, event: &[u8]) -> Message { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => { - let mut out = std::ptr::null_mut(); - let mut out_len: usize = 0; - let mut ret = (plugin.call_with_out_data)( - METHOD_HANDLE_PEER.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - &mut out as _, - &mut out_len as _, - ); - if ret.is_success() { - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } else { - let (code, msg) = ret.get_code_msg(id); - if code > ERR_RUSTDESK_HANDLE_BASE && code < ERR_PLUGIN_HANDLE_BASE { - log::debug!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let name = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => &plugin.desc.meta().name, - None => "???", - } - .to_owned(); - match code { - ERR_CALL_NOT_SUPPORTED_METHOD => { - make_plugin_failure(id, &name, "Plugin method is not supported") - } - ERR_CALL_INVALID_ARGS => { - make_plugin_failure(id, &name, "Plugin arguments is invalid") - } - _ => make_plugin_failure(id, &name, &msg), - } - } else { - log::error!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } - } - } - None => make_plugin_failure(id, "", "Plugin not found"), - } -} - -fn make_plugin_request(id: &str, content: *const c_void, len: usize) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_request(PluginRequest { - id: id.to_owned(), - content: unsafe { std::slice::from_raw_parts(content as *const u8, len) } - .clone() - .into(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn make_plugin_failure(id: &str, name: &str, msg: &str) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_failure(PluginFailure { - id: id.to_owned(), - name: name.to_owned(), - msg: msg.to_owned(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn reload_ui(desc: &Desc, sync_to: Option<&str>) { - for (location, ui) in desc.location().ui.iter() { - if let Ok(ui) = serde_json::to_string(&ui) { - let make_event = |ui: &str| { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_RELOAD); - m.insert("id", &desc.meta().id); - m.insert("location", &location); - // Do not depend on the "location" and plugin desc on the ui side. - // Send the ui field to ensure the ui is valid. - m.insert("ui", ui); - serde_json::to_string(&m).unwrap_or("".to_owned()) - }; - match sync_to { - Some(channel) => { - let _res = flutter::push_global_event(channel, make_event(&ui)); - } - None => { - let v: Vec<&str> = location.split('|').collect(); - // The first element is the "client" or "host". - // The second element is the "main", "remote", "cm", "file transfer", "port forward". - if v.len() >= 2 { - let available_channels = flutter::get_global_event_channels(); - if available_channels.contains(&v[1]) { - let _res = flutter::push_global_event(v[1], make_event(&ui)); - } - } - } - } - } - } -} - -pub(super) fn get_plugin_infos() -> Arc>> { - PLUGIN_INFO.clone() -} - -pub(super) fn get_desc_conf(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.config().clone()) -} - -pub(super) fn get_version(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.meta().version.clone()) -} diff --git a/src/port_forward.rs b/src/port_forward.rs index 9c013095126..7a3f8715ccb 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -69,7 +69,8 @@ pub async fn listen( let id = id.clone(); let password = password.clone(); let mut forward = Framed::new(forward, BytesCodec::new()); - match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp).await { + let mut close_port_forward = false; + match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward).await { Ok(Some(stream)) => { let interface = interface.clone(); tokio::spawn(async move { @@ -79,6 +80,9 @@ pub async fn listen( log::info!("connection from {:?} closed", addr); }); } + _ if close_port_forward => { + break; + } Err(err) => { interface.on_establish_connection_error(err.to_string()); } @@ -111,6 +115,7 @@ async fn connect_and_login( key: &str, token: &str, is_rdp: bool, + close_port_forward: &mut bool, ) -> ResultType> { let conn_type = if is_rdp { ConnType::RDP @@ -120,6 +125,12 @@ async fn connect_and_login( let ((mut stream, direct, _pk, _kcp, _stream_type), (feedback, rendezvous_server)) = Client::start(id, key, token, conn_type, interface.clone()).await?; interface.update_direct(Some(direct)); + if !stream.is_secured() && !crate::common::is_direct_ip_access(id) { + if !confirm_insecure_connection(&interface, ui_receiver).await { + *close_port_forward = true; + return Ok(None); + } + } let mut buffer = Vec::new(); let mut received = false; @@ -139,7 +150,9 @@ async fn connect_and_login( let msg_in = Message::parse_from_bytes(&bytes)?; match msg_in.union { Some(message::Union::Hash(hash)) => { - interface.handle_hash(password, hash, &mut stream).await; + if !interface.handle_hash(password, hash, &mut stream).await { + return Ok(None); + } } Some(message::Union::LoginResponse(lr)) => match lr.union { Some(login_response::Union::Error(err)) => { diff --git a/src/server.rs b/src/server.rs index 89a17a91963..5af98277289 100644 --- a/src/server.rs +++ b/src/server.rs @@ -44,6 +44,8 @@ mod clipboard_service; pub use clipboard_service::is_clipboard_service_ok; #[cfg(target_os = "linux")] pub(crate) mod wayland; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) mod drm_capturer; #[cfg(target_os = "linux")] pub mod uinput; #[cfg(target_os = "linux")] @@ -357,15 +359,13 @@ impl Server { } } - pub fn try_add_primay_video_service(&mut self) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); - if !self.contains(&primary_video_service_name) { + pub fn try_add_monitor_service(&mut self, display_idx: usize) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); + if !self.contains(&monitor_service_name) { self.add_service(Box::new(video_service::new( VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, + display_idx, ))); } } @@ -381,14 +381,17 @@ impl Server { self.connections.insert(conn.id(), conn); } - pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); + pub fn add_monitor_connection( + &mut self, + conn: ConnInner, + noperms: &Vec<&'static str>, + display_idx: usize, + ) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); for s in self.services.values() { let name = s.name(); - if Self::is_video_service_name(&name) && name != primary_video_service_name { + if Self::is_video_service_name(&name) && name != monitor_service_name { continue; } if !noperms.contains(&(&name as _)) { @@ -598,6 +601,25 @@ pub async fn start_server(is_server: bool, no_server: bool) { std::process::exit(-1); } }); + // Warm the DRM availability cache before any client connects, so the first connection does + // not race a cold `_drm` probe and ship an empty display list ("No displays" + retry). + // X11 is skipped -- probing there makes the root service open DRM readers for a path this + // session can never take -- but that decision belongs to `warm_availability`, which already + // makes it, and NOT to this call site. Deciding it here is the same one-shot-at-startup + // mistake the pre-warm had: `is_x11()` answers "x11" whenever loginctl cannot yet name the + // seat0 session, which during a boot is exactly when this runs, and nothing revisits it -- + // so a Wayland host that came up slowly skipped the warm for the life of the process and + // got back the cold-probe "No displays" symptom the warm exists to remove. + #[cfg(all(target_os = "linux", feature = "drm"))] + if let Err(err) = std::thread::Builder::new() + .name("drm-warm".into()) + .spawn(drm_capturer::warm_availability) + { + // Same reason as the root service's startup threads: `thread::spawn` panics on EAGAIN + // and that would abort `start_server`. Skipping the warm costs the first session the + // cold probe, which is what happened before the warm existed. + log::warn!("drm: could not spawn the availability warm ({err}); skipping it"); + } input_service::fix_key_down_timeout_loop(); #[cfg(target_os = "linux")] if input_service::wayland_use_uinput() { @@ -783,8 +805,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option) -> Vec { + /// Reading the sample bytes back as `f32` needs a 4-byte aligned pointer. + /// Returns an aligned copy only when `data` is not already aligned; `None` + /// means the caller can reinterpret `data` where it is, with no copy. + fn align_to_32_if_needed(data: &[u8]) -> Option { if (data.as_ptr() as usize & 3) == 0 { - return data; + return None; } - - let mut buf = vec![]; - buf = unsafe { hbb_common::mem::aligned_u8_vec(data.len(), 4) }; - buf.extend_from_slice(data.as_ref()); - buf + let mut buf = hbb_common::mem::aligned_u8_vec(data.len(), 4); + buf.extend_from_slice(data); + Some(buf) } #[tokio::main(flavor = "current_thread")] @@ -131,21 +131,28 @@ mod pa_impl { continue; } - let data = unsafe { align_to_32(data.into()) }; + let data: Vec = data.into(); + let aligned = align_to_32_if_needed(&data); + let bytes = aligned.as_deref().unwrap_or(&data[..]); + // SAFETY: `bytes` is 4-byte aligned (either checked above or freshly + // allocated with align 4), and only whole f32s are read from it. let data = unsafe { - std::slice::from_raw_parts::(data.as_ptr() as _, data.len() / 4) + std::slice::from_raw_parts::(bytes.as_ptr() as _, bytes.len() / 4) }; send_f32(data, &mut encoder, &sp); } #[cfg(target_os = "android")] if scrap::android::ffi::get_audio_raw(&mut android_data, &mut vec![]).is_some() { + // Keep `android_data` as the reusable receive buffer: overwriting it with + // an exact-capacity aligned buffer only made the next `get_audio_raw` + // reallocate it, which dropped the alignment again. + let aligned = align_to_32_if_needed(&android_data); + let bytes = aligned.as_deref().unwrap_or(&android_data[..]); + // SAFETY: `bytes` is 4-byte aligned (either checked above or freshly + // allocated with align 4), and only whole f32s are read from it. let data = unsafe { - android_data = align_to_32(android_data); - std::slice::from_raw_parts::( - android_data.as_ptr() as _, - android_data.len() / 4, - ) + std::slice::from_raw_parts::(bytes.as_ptr() as _, bytes.len() / 4) }; send_f32(data, &mut encoder, &sp); } else { diff --git a/src/server/connection.rs b/src/server/connection.rs index fb55a16aac5..fb7d1a2fe50 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -73,8 +73,16 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE}; use crate::virtual_display_manager; pub type Sender = mpsc::UnboundedSender<(Instant, Arc)>; +const FAILURE_IDX_ID_WHITELIST: usize = 2; +// How long a rejection counts, so also how long a blocked address stays blocked. Longer +// throttles enumeration harder; shorter limits collateral on whitelisted neighbours. +const ID_WHITELIST_FAILURE_DECAY_MINUTES: i32 = 10; + lazy_static::lazy_static! { - static ref LOGIN_FAILURES: [Arc::>>; 2] = Default::default(); + // [0] password, [1] 2FA, [2] ID whitelist. + // Bucket 2 is separate so its rejections do not touch the password / 2FA budgets. It is + // decayed in `check_id_whitelist` and cleared on auth, never on a bare id match. + static ref LOGIN_FAILURES: [Arc::>>; 3] = Default::default(); static ref SESSIONS: Arc::>> = Default::default(); static ref ALIVE_CONNS: Arc::>> = Default::default(); pub static ref AUTHED_CONNS: Arc::>> = Default::default(); @@ -83,11 +91,15 @@ lazy_static::lazy_static! { static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const SWITCH_SIDES_UUID_TTL: Duration = Duration::from_secs(10); + #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] lazy_static::lazy_static! { static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); - static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); + static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); } #[cfg(target_os = "windows")] @@ -110,9 +122,21 @@ fn should_check_linux_headless_os_auth_before_desktop_start( is_headless_allowed: bool, username: &str, ) -> bool { - is_headless_allowed - && !username.trim().is_empty() - && linux_desktop_manager::get_username().is_empty() + is_headless_allowed && !username.trim().is_empty() +} + +#[cfg(target_os = "linux")] +fn linux_desktop_start_credentials( + is_headless_allowed: bool, + os_login: Option<&OSLogin>, +) -> Option<(String, String)> { + if !is_headless_allowed { + return None; + } + if let Some(os_login) = os_login.filter(|os_login| !os_login.username.trim().is_empty()) { + return Some((os_login.username.clone(), os_login.password.clone())); + } + Some((String::new(), String::new())) } #[cfg(target_os = "linux")] @@ -139,43 +163,6 @@ pub static CLICK_TIME: AtomicI64 = AtomicI64::new(0); #[cfg(not(any(target_os = "android", target_os = "ios")))] pub static MOUSE_MOVE_TIME: AtomicI64 = AtomicI64::new(0); -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -lazy_static::lazy_static! { - static ref PLUGIN_BLOCK_INPUT_TXS: Arc>>> = Default::default(); - static ref PLUGIN_BLOCK_INPUT_TX_RX: (Arc>>, Arc>>) = { - let (tx, rx) = std_mpsc::channel(); - (Arc::new(Mutex::new(tx)), Arc::new(Mutex::new(rx))) - }; -} - -// Block input is required for some special cases, such as privacy mode. -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn plugin_block_input(peer: &str, block: bool) -> bool { - if let Some(tx) = PLUGIN_BLOCK_INPUT_TXS.lock().unwrap().get(peer) { - let _ = tx.send(if block { - MessageInput::BlockOnPlugin(peer.to_string()) - } else { - MessageInput::BlockOffPlugin(peer.to_string()) - }); - match PLUGIN_BLOCK_INPUT_TX_RX - .1 - .lock() - .unwrap() - .recv_timeout(std::time::Duration::from_millis(3_000)) - { - Ok(b) => b == block, - Err(..) => { - log::error!("plugin_block_input timeout"); - false - } - } - } else { - false - } -} - #[derive(Clone, Default)] pub struct ConnInner { id: i32, @@ -201,12 +188,6 @@ enum MessageInput { Pointer((PointerDeviceEvent, i32)), BlockOn, BlockOff, - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - BlockOnPlugin(String), - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - BlockOffPlugin(String), } #[derive(Clone, Debug, Hash, Eq, PartialEq)] @@ -240,6 +221,48 @@ pub enum AuthConnType { Terminal, } +impl AuthConnType { + fn as_str(self) -> &'static str { + match self { + AuthConnType::Remote => "remote", + AuthConnType::FileTransfer => "file_transfer", + AuthConnType::PortForward => "port_forward", + AuthConnType::ViewCamera => "view_camera", + AuthConnType::Terminal => "terminal", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(i64)] +enum ConnAuditPrimaryAuth { + None = 0, + Click = 1, + TemporaryPassword = 2, + PermanentPassword = 3, + SwitchSides = 4, +} + +impl ConnAuditPrimaryAuth { + fn as_i64(self) -> i64 { + self as i64 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(i64)] +enum ConnAuditTwoFactor { + None = 0, + Totp = 1, + TrustedDevice = 2, +} + +impl ConnAuditTwoFactor { + fn as_i64(self) -> i64 { + self as i64 + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Clone, Debug)] enum TerminalUserToken { @@ -275,6 +298,7 @@ pub struct Connection { tx_to_cm: mpsc::UnboundedSender, authorized: bool, require_2fa: Option, + awaiting_2fa: bool, keyboard: bool, clipboard: bool, audio: bool, @@ -312,6 +336,9 @@ pub struct Connection { server_audit_file: String, controlled_context: Option, lr: LoginRequest, + // Authentication retries may update credentials, but not the requested session scope. + // A digest, so no peer-controlled strings are retained. + login_scope: Option<[u8; 32]>, peer_argb: u32, session_last_recv_time: Option>>, chat_unanswered: bool, @@ -345,6 +372,8 @@ pub struct Connection { // For post requests that need to be sent sequentially. // eg. post_conn_audit tx_post_seq: mpsc::UnboundedSender<(String, Value)>, + conn_audit_primary_auth: ConnAuditPrimaryAuth, + conn_audit_two_factor: ConnAuditTwoFactor, // Tracks read job IDs delegated to CM process. // When a read job is delegated to CM (via FS::ReadFile), the job id is added here. // Used to filter stale responses (FileBlockFromCM, FileReadDone, etc.) for @@ -352,6 +381,8 @@ pub struct Connection { cm_read_job_ids: HashSet, terminal_service_id: String, terminal_persistent: bool, + // Used to avoid too many repeated scope violation warnings. + scope_violation_messages: HashSet<&'static str>, // The user token must be set when terminal is enabled. // 0 indicates SYSTEM user // other values indicate current user @@ -402,6 +433,24 @@ const SEND_TIMEOUT_VIDEO: u64 = 12_000; const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10; const SESSION_TIMEOUT: Duration = Duration::from_secs(30); +/// Whether the DRM backend can serve a Wayland login screen here. +/// +/// The cached probe, not the blocking one: this is a routing gate. Available-only ON PURPOSE, and +/// deliberately NOT symmetric with the seat0 adoption gate: that one only starts Xorg on a +/// definitive Unavailable (never over a maybe-live greeter), while admission only accepts on a +/// definitive Available (never a greeter nothing can yet capture). Both err toward refuse-and-retry +/// during an unsettled probe; admitting there would black-screen a client on a helper-less box. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_can_serve_login_screen() -> bool { + super::drm_capturer::is_available_cached() +} + +/// Without the feature nothing can capture a Wayland greeter, so the refusal stands. +#[cfg(all(target_os = "linux", not(feature = "drm")))] +fn drm_can_serve_login_screen() -> bool { + false +} + impl Connection { pub async fn start( addr: SocketAddr, @@ -457,7 +506,10 @@ impl Connection { tx_video: Some(tx_video), }, require_2fa: crate::auth_2fa::get_2fa(None), - display_idx: *display_service::PRIMARY_DISPLAY_IDX, + awaiting_2fa: false, + // Defer display enumeration until login succeeds. Monitor login replaces this + // with the primary index returned with the refreshed display snapshot. + display_idx: 0, stream, server, hash, @@ -502,6 +554,7 @@ impl Connection { server_audit_file: "".to_owned(), controlled_context, lr: Default::default(), + login_scope: None, peer_argb: 0u32, session_last_recv_time: None, chat_unanswered: false, @@ -539,9 +592,12 @@ impl Connection { cm_read_job_ids: HashSet::new(), terminal_service_id: "".to_owned(), terminal_persistent: false, + scope_violation_messages: HashSet::new(), #[cfg(not(any(target_os = "android", target_os = "ios")))] terminal_user_token: None, terminal_generic_service: None, + conn_audit_primary_auth: ConnAuditPrimaryAuth::None, + conn_audit_two_factor: ConnAuditTwoFactor::None, }; let addr = hbb_common::try_into_v4(addr); if !conn.on_open(addr).await { @@ -625,6 +681,7 @@ impl Connection { Some(data) = rx_from_cm.recv() => { match data { ipc::Data::Authorize => { + conn.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::Click); conn.require_2fa.take(); if !conn.send_logon_response_and_keep_alive().await { break; @@ -1076,12 +1133,6 @@ impl Connection { let _ = Self::turn_off_privacy_to_msg(id, String::new()); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - crate::plugin::handle_listen_event( - crate::plugin::EVENT_ON_CONN_CLOSE_SERVER.to_owned(), - conn.lr.my_id.clone(), - ); video_service::notify_video_frame_fetched_by_conn_id(id, None); if conn.authorized { password::update_temporary_password(); @@ -1166,35 +1217,8 @@ impl Connection { ); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOnPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(true); - if ok { - block_input_mode = true; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOffPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(false); - if ok { - block_input_mode = false; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } }, Err(err) => { - #[cfg(not(any(target_os = "android", target_os = "ios")))] if block_input_mode { let _ = crate::platform::block_input(true); } @@ -1323,6 +1347,64 @@ impl Connection { true } + async fn check_id_whitelist(&mut self) -> bool { + let id_whitelist: Vec = Config::get_option(keys::OPTION_ID_WHITELIST) + .split(',') + .map(|x| x.trim().to_owned()) + .filter(|x| !x.is_empty()) + .collect(); + if id_whitelist.is_empty() { + return true; + } + // Limit before matching, or a match returning early would never touch the counter and + // leave enumeration unthrottled. Not cleared here: `my_id` is self-reported, so that + // would let anyone holding one allowed id reset the budget between probes. + self.decay_id_whitelist_failures(); + let (failure, res) = self.check_failure(FAILURE_IDX_ID_WHITELIST).await; + if !res { + return false; + } + if id_whitelist_allows(&id_whitelist, &self.lr.my_id) { + return true; + } + self.update_failure(failure, false, FAILURE_IDX_ID_WHITELIST); + self.send_login_error("Your ID is blocked by the peer") + .await; + self.post_alarm_audit( + AlarmAuditType::IdWhitelist, + json!({ "id": self.lr.my_id.clone(), "ip": self.ip.clone(), "name": self.lr.my_name.clone() }), + ); + false + } + + // What `check_failure` consults: the source address, plus shared IPv6 prefixes. + fn failure_keys(&self) -> Vec { + let mut keys = vec![self.ip.clone()]; + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + keys.extend([p64, p56, p48]); + } + keys + } + + // Only this connection's own keys, so it stays O(1) instead of scanning the map. + fn decay_id_whitelist_failures(&self) { + decay_stale_failures( + &mut LOGIN_FAILURES[FAILURE_IDX_ID_WHITELIST].lock().unwrap(), + &self.failure_keys(), + (get_time() / 60_000) as i32, + ID_WHITELIST_FAILURE_DECAY_MINUTES, + ); + } + + // Not `update_failure(.., true, ..)`: it no-ops when the peer's own address has no entry, + // normal on IPv6, leaving the shared prefixes that are what actually block it. + fn clear_id_whitelist_failures(&self) { + clear_failures( + &mut LOGIN_FAILURES[FAILURE_IDX_ID_WHITELIST].lock().unwrap(), + &self.failure_keys(), + ); + } + async fn on_open(&mut self, addr: SocketAddr) -> bool { log::debug!("#{} Connection opened from {}.", self.inner.id, addr); if !self.check_whitelist(&addr).await { @@ -1386,6 +1468,8 @@ impl Connection { v["uuid"] = json!(crate::encode64(hbb_common::get_uuid())); v["conn_id"] = json!(self.inner.id); v["session_id"] = json!(self.lr.session_id); + // Unique per record; the api server dedups retried posts by it. + v["nonce"] = json!(uuid::Uuid::new_v4().to_string()); allow_err!(self.tx_post_seq.send((url, v))); } @@ -1435,6 +1519,7 @@ impl Connection { "path":path, "is_file":is_file, "info":json!(info).to_string(), + "nonce": uuid::Uuid::new_v4().to_string(), }); tokio::spawn(async move { allow_err!(Self::post_audit_async(url, v).await); @@ -1456,7 +1541,8 @@ impl Connection { v["typ"] = json!(typ as i8); v["info"] = serde_json::Value::String(info.to_string()); v["conn_id"] = json!(self.inner.id()); - if typ == AlarmAuditType::IpWhitelist { + v["nonce"] = json!(uuid::Uuid::new_v4().to_string()); + if typ == AlarmAuditType::IpWhitelist || typ == AlarmAuditType::IdWhitelist { if let Some(audit_ref) = self.conn_audit_ref() { v["conn_audit_ref"] = json!(audit_ref); } @@ -1466,9 +1552,137 @@ impl Connection { }); } - #[inline] + fn post_session_scope_violation_alarm(&self, message: &'static str) { + let conn_type = self + .authed_conn_type() + .map(AuthConnType::as_str) + .unwrap_or("unknown"); + self.post_alarm_audit( + AlarmAuditType::SessionScopeViolation, + json!({ + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + "ip": &self.ip, + "conn_type": conn_type, + "message": message, + }), + ); + } + async fn post_audit_async(url: String, v: Value) -> ResultType { - crate::post_request(url, v.to_string(), "").await + // Audit records are compliance evidence; retry transport errors and + // 5xx (e.g. a reverse proxy answering while the api server restarts) + // so transient failures don't silently drop them. A 4xx is a + // deterministic rejection and fails immediately. + // + // The delays, not the attempt count, are what cover the case this exists + // for: a proxy answering 502 during a restart fails fast, so without them + // every attempt lands within a few seconds and none outlives the restart. + // + // The window is bounded on the other side: the api server only remembers a + // record's nonce for five minutes, so a retry arriving after that expired + // would be stored a second time. Counting attempts cannot bound it - one + // attempt is already up to 84s (post_request_ retries the TLS handshake up + // to four times at 12s each, then the TCP-proxy fallback adds 36s), and a + // suspend between attempts stretches the wall clock without limit. So stop + // by elapsed time instead, early enough that the last attempt still lands + // inside the server's window. + const RETRY_DEADLINE: Duration = Duration::from_secs(120); + // One delay per retry, so the attempt count follows from the table and the + // two cannot drift apart. + const RETRY_BACKOFF_SECS: [u64; 2] = [10, 30]; + const ATTEMPTS: usize = RETRY_BACKOFF_SECS.len() + 1; + let body = v.to_string(); + let started = Instant::now(); + let mut attempt = 0usize; + loop { + attempt += 1; + let (retryable, err) = + match crate::post_request_with_status(url.clone(), body.clone(), "").await { + Ok((status, text)) => { + if (200..300).contains(&status) { + // Success is an empty body. hbbs reports handler + // failures (e.g. a db write error) as 200 with an + // {"error": ...} body - retryable: the server + // releases the record's nonce when its write fails, + // so trying again is what stores the record. Any + // other nonempty body did not come from the audit + // handler (a proxy interposing a 2xx maintenance + // page, a malformed error) and must not be mistaken + // for storage, so it is retried rather than dropped. + if text.trim().is_empty() { + return Ok(text); + } + let server_err = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("error")?.as_str().map(|s| s.to_owned())) + .filter(|e| !e.is_empty()); + let (label, detail) = match &server_err { + Some(e) => ("server error", e.as_str()), + None => ("unexpected response body", text.as_str()), + }; + let brief: String = detail.chars().take(128).collect(); + (true, format!("{}: {}", label, brief)) + } else { + let brief: String = text.chars().take(128).collect(); + // 408 and 429 are the transient 4xx: the request timed + // out upstream, or a proxy is shedding load. Every other + // 4xx is a deterministic rejection and retrying it would + // only delay the log line. + let transient = status >= 500 || status == 408 || status == 429; + (transient, format!("status {}: {}", status, brief)) + } + } + Err(e) => (true, e.to_string()), + }; + let elapsed = started.elapsed(); + if !retryable || attempt >= ATTEMPTS || elapsed >= RETRY_DEADLINE { + log::error!( + "Audit post dropped (attempt {}/{}, {:?} elapsed): {}", + attempt, + ATTEMPTS, + elapsed, + err + ); + bail!("{}", err); + } + log::warn!( + "Audit post failed (attempt {}/{}): {}", + attempt, + ATTEMPTS, + err + ); + // In range by construction: the guard above returns at ATTEMPTS. + time::sleep(Duration::from_secs(RETRY_BACKOFF_SECS[attempt - 1])).await; + // Re-checked after the delay so no attempt starts past the deadline; + // the check above alone would let one begin up to a backoff later. + if started.elapsed() >= RETRY_DEADLINE { + log::error!( + "Audit post dropped (attempt {}/{}, deadline passed during backoff): {}", + attempt, + ATTEMPTS, + err + ); + bail!("{}", err); + } + } + } + + fn set_conn_audit_primary_auth(&mut self, method: ConnAuditPrimaryAuth) { + self.conn_audit_primary_auth = method; + } + + fn set_conn_audit_two_factor(&mut self, two_factor: ConnAuditTwoFactor) { + self.conn_audit_two_factor = two_factor; + } + + fn normalize_conn_audit_auth_fields(&mut self) { + if matches!( + self.conn_audit_primary_auth, + ConnAuditPrimaryAuth::Click | ConnAuditPrimaryAuth::SwitchSides + ) { + self.conn_audit_two_factor = ConnAuditTwoFactor::None; + } } fn normalize_port_forward_target(pf: &mut PortForward) -> (String, bool) { @@ -1560,10 +1774,12 @@ impl Connection { }); } }); + self.awaiting_2fa = true; self.send_login_error(crate::client::REQUIRE_2FA).await; // Keep the connection alive so the client can continue with 2FA. return true; } + self.awaiting_2fa = false; if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await { return keep_alive; } @@ -1571,6 +1787,9 @@ impl Connection { return false; } self.authorized = true; + // Releases the budget `check_id_whitelist` charges against this address: only a peer + // that got this far proved more than a self-reported id. + self.clear_id_whitelist_failures(); let (conn_type, auth_conn_type) = if self.file_transfer.is_some() { (1, AuthConnType::FileTransfer) } else if self.port_forward_socket.is_some() { @@ -1594,10 +1813,15 @@ impl Connection { .unwrap() .get(&self.session_key()) .map(|s| s.last_recv_time.clone()); - self.post_conn_audit(json!({ - "peer": ((&self.lr.my_id, &self.lr.my_name)), - "type": conn_type, - })); + self.normalize_conn_audit_auth_fields(); + let mut audit = json!({"peer": ((&self.lr.my_id, &self.lr.my_name)), "type": conn_type}); + if self.conn_audit_primary_auth != ConnAuditPrimaryAuth::None { + audit["primary_auth"] = json!(self.conn_audit_primary_auth.as_i64()); + } + if self.conn_audit_two_factor != ConnAuditTwoFactor::None { + audit["two_factor"] = json!(self.conn_audit_two_factor.as_i64()); + } + self.post_conn_audit(audit); #[allow(unused_mut)] let mut username = crate::platform::get_active_username(); let mut res = LoginResponse::new(); @@ -1697,7 +1921,8 @@ impl Connection { #[cfg(target_os = "linux")] if self.is_remote() { let mut msg = "".to_string(); - if crate::platform::linux::is_login_screen_wayland() { + // Refuse only while nothing can capture a Wayland greeter: the DRM path can. + if crate::platform::linux::is_login_screen_wayland() && !drm_can_serve_login_screen() { msg = crate::client::LOGIN_SCREEN_WAYLAND.to_owned() } else { let dtype = crate::platform::linux::get_display_server(); @@ -1731,13 +1956,6 @@ impl Connection { username = "".to_owned(); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - PLUGIN_BLOCK_INPUT_TXS - .lock() - .unwrap() - .insert(self.lr.my_id.clone(), self.tx_input.clone()); - // Terminal feature is supported on desktop only #[allow(unused_mut)] let mut terminal = cfg!(not(any(target_os = "android", target_os = "ios"))); @@ -1802,13 +2020,15 @@ impl Connection { Err(err) => { res.set_error(format!("{}", err)); } - Ok(displays) => { + Ok((displays, primary_display_idx)) => { // For compatibility with old versions, we need to send the displays to the peer. // But the displays may be updated later, before creating the video capturer. #[cfg(target_os = "macos")] { self.retina.set_displays(&displays); } + // A separate primary lookup here could race with display hot-plug. + self.display_idx = primary_display_idx; pi.displays = displays; pi.current_display = self.display_idx as _; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -1840,10 +2060,9 @@ impl Connection { let mut msg_out = Message::new(); msg_out.set_login_response(res); self.send(msg_out).await; - if let Some(o) = self.options_in_login.take() { - self.update_options(&o).await; - } + self.update_scoped_login_options().await; if let Some((dir, show_hidden)) = self.file_transfer.clone() { + self.keyboard = false; let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() { &dir } else { @@ -1918,8 +2137,8 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] let _h = try_start_record_cursor_pos(); self.auto_disconnect_timer = Self::get_auto_disconenct_timer(); - s.try_add_primay_video_service(); - s.add_connection(self.inner.clone(), &noperms); + s.try_add_monitor_service(self.display_idx); + s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx); } } } @@ -2209,6 +2428,7 @@ impl Connection { if password::temporary_enabled() { let password = password::temporary_password(); if self.validate_password_plain(&password) { + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::TemporaryPassword); raii::AuthedConnID::update_or_insert_session( self.session_key(), Some(password), @@ -2232,6 +2452,7 @@ impl Connection { if local_permanent_password_storage_is_usable_for_auth(&local_storage, &local_salt) && self.validate_password_storage(&local_storage) { + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::PermanentPassword); print_fallback(); return true; } @@ -2240,6 +2461,7 @@ impl Connection { if preset_permanent_password_storage_is_usable_for_auth(&hard, &salt) && self.validate_preset_password_storage(&hard, &salt) { + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::PermanentPassword); print_fallback(); return true; } @@ -2264,6 +2486,11 @@ impl Connection { && (tfa && session.tfa || !tfa && self.validate_password_plain(&session.random_password)) { + if tfa { + self.set_conn_audit_two_factor(ConnAuditTwoFactor::Totp); + } else { + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::TemporaryPassword); + } log::info!("is recent session"); return true; } @@ -2342,6 +2569,98 @@ impl Connection { ) } + fn reset_session_scope_for_login(&mut self) { + self.file_transfer = None; + self.view_camera = false; + self.terminal = false; + self.port_forward_address.clear(); + self.terminal_persistent = false; + } + + // Approval and whitelist decisions must stay bound to the same controller identity and + // session scope across authentication retries. + fn login_scope_digest(lr: &LoginRequest) -> [u8; 32] { + let mut hasher = Sha256::new(); + // Length-prefixed so adjacent fields cannot alias. + let mut push = |bytes: &[u8]| { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + }; + push(lr.my_id.as_bytes()); + // Payloads are destructured exhaustively: a new field fails to compile until it is + // either latched here or deliberately ignored. + match lr.union.as_ref() { + Some(login_request::Union::FileTransfer(ft)) => { + let FileTransfer { + dir, + show_hidden, + special_fields: _, + } = ft; + push(b"file_transfer"); + push(dir.as_bytes()); + push(&[*show_hidden as u8]); + } + Some(login_request::Union::ViewCamera(vc)) => { + let ViewCamera { special_fields: _ } = vc; + push(b"view_camera"); + } + Some(login_request::Union::Terminal(t)) => { + let Terminal { + service_id, + special_fields: _, + } = t; + push(b"terminal"); + push(service_id.as_bytes()); + } + Some(login_request::Union::PortForward(pf)) => { + let PortForward { + host, + port, + special_fields: _, + } = pf; + push(b"port_forward"); + push(host.as_bytes()); + push(&port.to_le_bytes()); + } + // Variants this build does not know execute as remote, so they latch as remote. + None | Some(_) => push(b"remote"), + } + hasher.finalize().into() + } + + // Logging only; security decisions compare digests. + fn login_scope_kind(lr: &LoginRequest) -> &'static str { + match lr.union.as_ref() { + Some(login_request::Union::FileTransfer(_)) => "file_transfer", + Some(login_request::Union::ViewCamera(_)) => "view_camera", + Some(login_request::Union::Terminal(_)) => "terminal", + Some(login_request::Union::PortForward(_)) => "port_forward", + _ => "remote", + } + } + + async fn check_login_scope(&mut self, lr: &LoginRequest) -> bool { + let requested = Self::login_scope_digest(lr); + match self.login_scope { + Some(initial) if initial != requested => { + // self.lr still holds the first accepted request, whose scope is the latched one. + log::warn!( + "Rejected login scope change: conn_id={}, initial={}, requested={}", + self.inner.id(), + Self::login_scope_kind(&self.lr), + Self::login_scope_kind(lr), + ); + self.send_login_error("Connection not allowed").await; + false + } + Some(_) => true, + None => { + self.login_scope = Some(requested); + true + } + } + } + async fn handle_login_request_without_validation(&mut self, lr: &LoginRequest) { self.lr = lr.clone(); self.peer_argb = crate::str2color(&format!("{}{}", &lr.my_id, &lr.my_platform), 0xff); @@ -2357,6 +2676,7 @@ impl Connection { && device.platform == lr.my_platform { log::info!("2FA bypassed by trusted devices"); + self.set_conn_audit_two_factor(ConnAuditTwoFactor::TrustedDevice); self.require_2fa = None; } } @@ -2408,12 +2728,28 @@ impl Connection { return false; } } + if self.authorized { + if matches!(msg.union.as_ref(), Some(message::Union::LoginRequest(_))) { + return true; + } + if let Some(message) = self.authorized_scope_violation(&msg) { + return self.handle_authorized_scope_violation(message).await; + } + } // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { + if !self.check_login_scope(&lr).await { + return false; + } + self.awaiting_2fa = false; self.handle_login_request_without_validation(&lr).await; if self.authorized { return true; } + self.reset_session_scope_for_login(); + if !self.check_id_whitelist().await { + return false; + } match lr.union { Some(login_request::Union::FileTransfer(ft)) => { if !Self::permission( @@ -2473,9 +2809,7 @@ impl Connection { } } - if !hbb_common::is_ip_str(&lr.username) - && !hbb_common::is_domain_port_str(&lr.username) - && lr.username != Config::get_id() + if !crate::common::is_direct_ip_access(&lr.username) && lr.username != Config::get_id() { self.send_login_error(crate::client::LOGIN_MSG_OFFLINE) .await; @@ -2497,6 +2831,7 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + #[cfg(not(target_os = "linux"))] self.try_start_cm_ipc(); } @@ -2514,9 +2849,18 @@ impl Connection { #[cfg(not(target_os = "linux"))] let err_msg = "".to_owned(); #[cfg(target_os = "linux")] - let err_msg = self + let err_msg = match self .linux_headless_handle - .try_start_desktop(lr.os_login.as_ref()); + .try_start_desktop(lr.os_login.as_ref()) + .await + { + LinuxDesktopStartOutcome::Finished(err_msg) => err_msg, + LinuxDesktopStartOutcome::Busy => { + self.send_login_error(crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY) + .await; + return true; + } + }; // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY @@ -2537,6 +2881,12 @@ impl Connection { return true; } + #[cfg(target_os = "linux")] + if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + // In headless mode, the desktop check above settles the snapshot used by CM routing. + self.try_start_cm_ipc(); + } + // https://github.com/rustdesk/rustdesk-server-pro/discussions/646 // `is_logon` is used to check login with `OPTION_ALLOW_LOGON_SCREEN_PASSWORD` == "Y". // `is_logon_ui()` is a fallback for logon UI detection on Windows. @@ -2640,6 +2990,11 @@ impl Connection { } } } else if let Some(message::Union::Auth2fa(tfa)) = msg.union { + // A 2FA response may arrive after click authorization has completed. + // Ignore it unless this connection is still waiting for the response. + if !self.awaiting_2fa { + return true; + } let (failure, res) = self.check_failure(1).await; if !res { return true; @@ -2649,6 +3004,7 @@ impl Connection { if res { self.update_failure(failure, true, 1); self.require_2fa.take(); + self.set_conn_audit_two_factor(ConnAuditTwoFactor::Totp); raii::AuthedConnID::set_session_2fa(self.session_key()); if !self.send_logon_response_and_keep_alive().await { return false; @@ -2694,16 +3050,30 @@ impl Connection { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { - self.handle_login_request_without_validation(&lr).await; SWITCH_SIDES_UUID .lock() .unwrap() - .retain(|_, v| v.0.elapsed() < Duration::from_secs(10)); + .retain(|_, v| v.0.elapsed() < SWITCH_SIDES_UUID_TTL); let uuid_old = SWITCH_SIDES_UUID.lock().unwrap().remove(&lr.my_id); if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { + if lr.union.is_some() { + log::warn!( + "Rejected switch sides response for non-remote-desktop session; closing connection" + ); + self.send_login_error("Connection not allowed").await; + return false; + } + self.reset_session_scope_for_login(); + self.handle_login_request_without_validation(&lr).await; + // Switching sides authorizes without a password, so it must not bypass + // the whitelist, which can be a locked policy pushed by the server. + if !self.check_id_whitelist().await { + return false; + } self.from_switch = true; + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { return false; } @@ -2921,7 +3291,7 @@ impl Connection { self.update_auto_disconnect_timer(); } Some(message::Union::Clipboard(cb)) => { - if self.clipboard_enabled() { + if self.should_handle_text_clipboard_message() && self.clipboard_enabled() { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Host); // ios as the controlled side is actually not supported for now. @@ -2949,7 +3319,7 @@ impl Connection { } } Some(message::Union::MultiClipboards(_mcb)) => { - if self.clipboard_enabled() { + if self.should_handle_text_clipboard_message() && self.clipboard_enabled() { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Host); #[cfg(target_os = "android")] @@ -3336,10 +3706,14 @@ impl Connection { } #[cfg(windows)] Some(misc::Union::ToggleVirtualDisplay(t)) => { - self.toggle_virtual_display(t).await; + if !self.view_camera { + self.toggle_virtual_display(t).await; + } } Some(misc::Union::TogglePrivacyMode(t)) => { - self.toggle_privacy_mode(t).await; + if !self.view_camera { + self.toggle_privacy_mode(t).await; + } } Some(misc::Union::ChatMessage(c)) => { self.send_to_cm(ipc::Data::ChatMessage { text: c.text }); @@ -3347,19 +3721,27 @@ impl Connection { self.update_auto_disconnect_timer(); } Some(misc::Union::Option(o)) => { - self.update_options(&o).await; + if self.authed_conn_type() == Some(AuthConnType::Remote) { + self.update_options(&o).await; + } else if let Some(option) = self.scoped_update_option_message(&o) { + self.update_options(&option).await; + } } Some(misc::Union::RefreshVideo(r)) => { - if r { - // Refresh all videos. - // Compatibility with old versions and sciter(remote). - self.refresh_video_display(None); + if self.should_handle_render_broadcast_message() { + if r { + // Refresh all videos. + // Compatibility with old versions and sciter(remote). + self.refresh_video_display(None); + } + self.update_auto_disconnect_timer(); } - self.update_auto_disconnect_timer(); } Some(misc::Union::RefreshVideoDisplay(display)) => { - self.refresh_video_display(Some(display as usize)); - self.update_auto_disconnect_timer(); + if self.should_handle_render_broadcast_message() { + self.refresh_video_display(Some(display as usize)); + self.update_auto_disconnect_timer(); + } } Some(misc::Union::VideoReceived(_)) => { video_service::notify_video_frame_fetched_by_conn_id( @@ -3411,33 +3793,33 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchSidesRequest(s)) => { if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) { - crate::server::insert_pending_switch_sides_uuid( + if crate::server::insert_pending_switch_sides_uuid( self.lr.my_id.clone(), uuid.clone(), - ); - crate::run_me(vec![ - "--connect", - &self.lr.my_id, - "--switch_uuid", - uuid.to_string().as_ref(), - ]) - .ok(); + ) { + crate::run_me(vec![ + "--connect", + &self.lr.my_id, + "--switch_uuid", + uuid.to_string().as_ref(), + ]) + .ok(); + } self.on_close("switch sides", false).await; return false; } } #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::ChangeResolution(r)) => self.change_resolution(None, &r), - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::ChangeDisplayResolution(dr)) => { - self.change_resolution(Some(dr.display as _), &dr.resolution) + Some(misc::Union::ChangeResolution(r)) => { + if !self.view_camera { + self.change_resolution(None, &r); + } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::PluginRequest(p)) => { - let msg = - crate::plugin::handle_client_event(&p.id, &self.lr.my_id, &p.content); - self.send(msg).await; + Some(misc::Union::ChangeDisplayResolution(dr)) => { + if !self.view_camera { + self.change_resolution(Some(dr.display as _), &dr.resolution); + } } Some(misc::Union::AutoAdjustFps(fps)) => video_service::VIDEO_QOS .lock() @@ -3516,6 +3898,7 @@ impl Connection { Some(message::Union::ScreenshotRequest(request)) => { if let Some(tx) = self.inner.tx.clone() { crate::video_service::set_take_screenshot( + self.video_source(), request.display as _, request.sid.clone(), tx, @@ -4009,10 +4392,12 @@ impl Connection { let display_idx = s.display as usize; if self.display_idx != display_idx { if let Some(server) = self.server.upgrade() { - self.switch_display_to(display_idx, server.clone()); + if !self.switch_display_to(display_idx, server.clone()) { + return; + } #[cfg(not(any(target_os = "android", target_os = "ios")))] - if s.width != 0 && s.height != 0 { + if !self.view_camera && s.width != 0 && s.height != 0 { self.change_resolution( None, &Resolution { @@ -4036,6 +4421,13 @@ impl Connection { } } + fn video_source_count(video_source: VideoSource) -> usize { + match video_source { + VideoSource::Monitor => display_service::get_sync_displays().len(), + VideoSource::Camera => camera::Cameras::get_sync_cameras().len(), + } + } + fn video_source(&self) -> VideoSource { if self.view_camera { VideoSource::Camera @@ -4044,18 +4436,28 @@ impl Connection { } } - fn switch_display_to(&mut self, display_idx: usize, server: Arc>) { + fn switch_display_to(&mut self, display_idx: usize, server: Arc>) -> bool { + let source_count = Self::video_source_count(self.video_source()); + if display_idx >= source_count { + // Do not remap an explicit switch: its resolution belongs to the requested source. + log::warn!( + "Ignore switch to invalid {:?} index {}, available source count: {}", + self.video_source(), + display_idx, + source_count + ); + return false; + } + let new_service_name = video_service::get_service_name(self.video_source(), display_idx); let old_service_name = video_service::get_service_name(self.video_source(), self.display_idx); let mut lock = server.write().unwrap(); - if display_idx != *display_service::PRIMARY_DISPLAY_IDX { - if !lock.contains(&new_service_name) { - lock.add_service(Box::new(video_service::new( - self.video_source(), - display_idx, - ))); - } + if !lock.contains(&new_service_name) { + lock.add_service(Box::new(video_service::new( + self.video_source(), + display_idx, + ))); } // For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately. // Unnecessary capturers will be removed then. @@ -4064,6 +4466,7 @@ impl Connection { } lock.subscribe(&new_service_name, self.inner.clone(), true); self.display_idx = display_idx; + true } #[cfg(windows)] @@ -4090,26 +4493,61 @@ impl Connection { async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) { let video_source = self.video_source(); - if let Some(sever) = self.server.upgrade() { - let mut lock = sever.write().unwrap(); - for display in add.iter() { + let source_count = Self::video_source_count(video_source); + // Only add/set can create services; sub only narrows existing subscriptions. + let valid_add = add + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_sub = sub + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_set = set + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let invalid_count = + add.len() + sub.len() + set.len() - valid_add.len() - valid_sub.len() - valid_set.len(); + if invalid_count != 0 { + log::warn!( + "Ignore {} invalid {:?} indices, available source count: {}", + invalid_count, + video_source, + source_count + ); + } + // Passing an invalid sub request as an empty exclude list would unsubscribe all services. + if (!add.is_empty() && valid_add.is_empty()) + || (add.is_empty() && !sub.is_empty() && valid_sub.is_empty()) + || (add.is_empty() && sub.is_empty() && !set.is_empty() && valid_set.is_empty()) + { + return; + } + + if let Some(server) = self.server.upgrade() { + let mut lock = server.write().unwrap(); + for display in valid_add.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } - for display in set.iter() { + for display in valid_set.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } if !add.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, add, true, false); + lock.capture_displays(self.inner.clone(), video_source, &valid_add, true, false); } else if !sub.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, sub, false, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_sub, false, true); } else { - lock.capture_displays(self.inner.clone(), video_source, set, true, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_set, true, true); } self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1; if self.follow_remote_window { @@ -4572,7 +5010,7 @@ impl Connection { } } else { crate::common::make_privacy_mode_msg( - back_notification::PrivacyModeState::PrvOnFailedPlugin, + back_notification::PrivacyModeState::PrvOnFailed, impl_key, ) } @@ -5136,6 +5574,399 @@ impl Connection { false } + fn should_handle_render_broadcast_message(&self) -> bool { + matches!( + self.authed_conn_type(), + Some(AuthConnType::Remote | AuthConnType::ViewCamera) + ) + } + + fn should_handle_text_clipboard_message(&self) -> bool { + matches!(self.authed_conn_type(), Some(AuthConnType::Remote)) + } + + fn scoped_update_option_message(&self, option: &OptionMessage) -> Option { + match self.authed_conn_type() { + Some(AuthConnType::ViewCamera) => Self::scoped_view_camera_option(option).0, + Some(AuthConnType::Terminal) => Self::scoped_terminal_login_option(option).0, + Some(AuthConnType::Remote | AuthConnType::FileTransfer | AuthConnType::PortForward) + | None => None, + } + } + + fn authed_conn_type(&self) -> Option { + self.authed_conn_id.as_ref().map(|id| id.conn_type()) + } + + async fn handle_authorized_scope_violation(&mut self, message: &'static str) -> bool { + let conn_type = self + .authed_conn_type() + .map(AuthConnType::as_str) + .unwrap_or("unknown"); + let is_first = self.scope_violation_messages.insert(message); + if is_first { + log::warn!( + "Received out-of-scope message in {} session: {}", + conn_type, + message + ); + } else { + log::debug!( + "Received repeated out-of-scope message in {} session: {}", + conn_type, + message + ); + } + if is_first && Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_ALARM) { + self.post_session_scope_violation_alarm(message); + } + if Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_CLOSE) { + self.send_close_reason_no_retry("Connection not allowed") + .await; + self.on_close("Session scope violation", true).await; + return false; + } + true + } + + fn authorized_scope_violation(&self, msg: &Message) -> Option<&'static str> { + let Some(conn_type) = self.authed_conn_type() else { + return (!Self::is_connection_housekeeping_message(msg)).then_some("session.auth_type"); + }; + Self::authorized_message_scope_violation(conn_type, msg) + } + + async fn update_scoped_login_options(&mut self) { + let Some(option) = self.options_in_login.take() else { + return; + }; + let Some(conn_type) = self.authed_conn_type() else { + // Unreachable, but just in case, we drop the options if the connection type is unknown. + log::warn!( + "Dropping scoped login options because authorized connection type is unknown" + ); + return; + }; + let (scoped, violation) = Self::scoped_login_option(conn_type, &option); + if let Some(message) = violation { + log::debug!( + "Filtering {} session login options outside scope: {}", + conn_type.as_str(), + message + ); + } + if let Some(option) = scoped { + self.update_options(&option).await; + } + } + + fn scoped_login_option( + conn_type: AuthConnType, + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + match conn_type { + AuthConnType::Remote => (Some(option.clone()), None), + AuthConnType::ViewCamera => Self::scoped_view_camera_option(option), + AuthConnType::Terminal => Self::scoped_terminal_login_option(option), + AuthConnType::FileTransfer | AuthConnType::PortForward => { + let violation = Self::option_has_any_field(option).then_some("login.option"); + (None, violation) + } + } + } + + fn scoped_terminal_login_option( + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + let mut scoped = OptionMessage::new(); + let mut violation = false; + match option.terminal_persistent.enum_value() { + Ok(value) => scoped.terminal_persistent = value.into(), + Err(_) => violation = true, + } + if Self::option_has_non_terminal_login_field(option) { + violation = true; + } + let scoped = Self::option_has_any_field(&scoped).then_some(scoped); + (scoped, violation.then_some("login.option")) + } + + fn authorized_message_scope_violation( + conn_type: AuthConnType, + msg: &Message, + ) -> Option<&'static str> { + if Self::is_connection_housekeeping_message(msg) { + return None; + } + // Legacy clients can broadcast render-refresh messages to all opened sessions. + // Clipboard messages may also be broadcast to FileTransfer/Terminal sessions while + // the client still considers text clipboard sync required, and handlers ignore them. + let noop_compat = match conn_type { + AuthConnType::FileTransfer | AuthConnType::Terminal => { + Self::is_render_broadcast_noop_compat_message(msg) + || Self::is_text_clipboard_noop_compat_message(msg) + } + AuthConnType::PortForward => Self::is_render_broadcast_noop_compat_message(msg), + AuthConnType::ViewCamera => Self::is_text_clipboard_noop_compat_message(msg), + _ => false, + }; + if noop_compat { + return None; + } + let allowed = match conn_type { + AuthConnType::Remote => true, + AuthConnType::FileTransfer => Self::is_file_transfer_scoped_message(msg), + AuthConnType::PortForward => false, + AuthConnType::ViewCamera => Self::is_view_camera_scoped_message(msg), + AuthConnType::Terminal => Self::is_terminal_scoped_message(msg), + }; + (!allowed).then(|| Self::message_family(msg)) + } + + fn is_render_broadcast_noop_compat_message(msg: &Message) -> bool { + let Some(message::Union::Misc(misc)) = msg.union.as_ref() else { + return false; + }; + match misc.union.as_ref() { + Some(misc::Union::RefreshVideo(_)) | Some(misc::Union::RefreshVideoDisplay(_)) => true, + Some(misc::Union::Option(option)) => Self::is_supported_decoding_only_option(option), + _ => false, + } + } + + fn is_text_clipboard_noop_compat_message(msg: &Message) -> bool { + matches!( + msg.union.as_ref(), + Some(message::Union::Clipboard(_)) | Some(message::Union::MultiClipboards(_)) + ) + } + + fn is_supported_decoding_only_option(option: &OptionMessage) -> bool { + option.supported_decoding.is_some() + && option.image_quality.enum_value() == Ok(ImageQuality::NotSet) + && option.custom_image_quality == 0 + && option.custom_fps == 0 + && Self::is_bool_option_not_set(option.lock_after_session_end) + && Self::is_bool_option_not_set(option.show_remote_cursor) + && Self::is_bool_option_not_set(option.privacy_mode) + && Self::is_bool_option_not_set(option.block_input) + && Self::is_bool_option_not_set(option.disable_audio) + && Self::is_bool_option_not_set(option.disable_clipboard) + && Self::is_bool_option_not_set(option.enable_file_transfer) + && Self::is_bool_option_not_set(option.disable_keyboard) + && Self::is_bool_option_not_set(option.follow_remote_cursor) + && Self::is_bool_option_not_set(option.follow_remote_window) + && Self::is_bool_option_not_set(option.disable_camera) + && Self::is_bool_option_not_set(option.terminal_persistent) + && Self::is_bool_option_not_set(option.show_my_cursor) + } + + fn is_connection_housekeeping_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::LoginRequest(_)) => true, + Some(message::Union::TestDelay(_)) => true, + Some(message::Union::Misc(misc)) => { + matches!(misc.union.as_ref(), Some(misc::Union::CloseReason(_))) + } + _ => false, + } + } + + fn is_file_transfer_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::FileAction(_)) | Some(message::Union::FileResponse(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_file_transfer_scoped_misc(misc), + _ => false, + } + } + + fn is_file_transfer_scoped_misc(misc: &Misc) -> bool { + #[cfg(windows)] + if matches!(misc.union.as_ref(), Some(misc::Union::SelectedSid(_))) { + return true; + } + #[cfg(not(windows))] + let _ = misc; + false + } + + fn is_terminal_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::TerminalAction(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_terminal_scoped_misc(misc), + _ => false, + } + } + + fn is_terminal_scoped_misc(misc: &Misc) -> bool { + match misc.union.as_ref() { + Some(misc::Union::ChatMessage(_)) => true, + Some(misc::Union::Option(option)) => Self::is_terminal_scoped_option(option), + _ => false, + } + } + + fn is_terminal_scoped_option(option: &OptionMessage) -> bool { + Self::scoped_terminal_login_option(option).1.is_none() + } + + fn is_view_camera_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::ScreenshotRequest(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_view_camera_scoped_misc(misc), + // Legacy clients may send auto-login input during view-camera connect. + // The handlers intentionally ignore these messages for view-camera sessions. + Some(message::Union::MouseEvent(_)) + | Some(message::Union::PointerDeviceEvent(_)) + | Some(message::Union::KeyEvent(_)) => true, + Some(message::Union::AudioFrame(_)) + | Some(message::Union::VoiceCallRequest(_)) + | Some(message::Union::VoiceCallResponse(_)) => true, + _ => false, + } + } + + fn is_view_camera_scoped_misc(misc: &Misc) -> bool { + match misc.union.as_ref() { + Some(misc::Union::SwitchDisplay(_)) + | Some(misc::Union::CaptureDisplays(_)) + | Some(misc::Union::RefreshVideo(_)) + | Some(misc::Union::RefreshVideoDisplay(_)) + | Some(misc::Union::VideoReceived(_)) + | Some(misc::Union::ChatMessage(_)) + | Some(misc::Union::AudioFormat(_)) + | Some(misc::Union::ClientRecordStatus(_)) + // Though these messages are not expected in normal view-camera sessions, + // keep them allowed to avoid breaking existing clients that may send them. + | Some(misc::Union::MessageQuery(_)) + | Some(misc::Union::TogglePrivacyMode(_)) + | Some(misc::Union::ToggleVirtualDisplay(_)) + | Some(misc::Union::ChangeResolution(_)) + | Some(misc::Union::ChangeDisplayResolution(_)) => true, + Some(misc::Union::Option(option)) => Self::is_view_camera_scoped_option(option), + #[cfg(windows)] + Some(misc::Union::SelectedSid(_)) => true, + _ => false, + } + } + + fn is_view_camera_scoped_option(option: &OptionMessage) -> bool { + Self::scoped_view_camera_option(option).1.is_none() + } + + // Keep these OptionMessage field lists in sync with message.proto and update_options(). + // New fields must be classified here before limited session types can receive them. + fn scoped_view_camera_option( + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + let mut scoped = OptionMessage::new(); + let mut violation = false; + if option.image_quality.enum_value().is_ok() { + scoped.image_quality = option.image_quality; + } + if option.custom_image_quality >= 0 { + scoped.custom_image_quality = option.custom_image_quality; + } + if option.custom_fps >= 0 { + scoped.custom_fps = option.custom_fps; + } + scoped.supported_decoding = option.supported_decoding.clone(); + if let Ok(value) = option.disable_audio.enum_value() { + scoped.disable_audio = value.into(); + } + if Self::option_has_non_view_camera_login_field(option) { + violation = true; + } + let scoped = Self::option_has_any_field(&scoped).then_some(scoped); + (scoped, violation.then_some("login.option")) + } + + fn option_has_non_view_camera_login_field(option: &OptionMessage) -> bool { + !(Self::is_bool_option_not_set(option.lock_after_session_end) + && Self::is_bool_option_not_set(option.show_remote_cursor) + && Self::is_bool_option_not_set(option.privacy_mode) + && Self::is_bool_option_not_set(option.block_input) + && Self::is_bool_option_not_set(option.disable_clipboard) + && Self::is_bool_option_not_set(option.enable_file_transfer) + && Self::is_bool_option_not_set(option.disable_keyboard) + && Self::is_bool_option_not_set(option.follow_remote_cursor) + && Self::is_bool_option_not_set(option.follow_remote_window) + && Self::is_bool_option_not_set(option.disable_camera) + && Self::is_bool_option_not_set(option.terminal_persistent) + && Self::is_bool_option_not_set(option.show_my_cursor)) + } + + fn option_has_non_terminal_login_field(option: &OptionMessage) -> bool { + option.image_quality.enum_value() != Ok(ImageQuality::NotSet) + || option.custom_image_quality != 0 + || option.custom_fps != 0 + || option.supported_decoding.is_some() + || !Self::is_bool_option_not_set(option.lock_after_session_end) + || !Self::is_bool_option_not_set(option.show_remote_cursor) + || !Self::is_bool_option_not_set(option.privacy_mode) + || !Self::is_bool_option_not_set(option.block_input) + || !Self::is_bool_option_not_set(option.disable_audio) + || !Self::is_bool_option_not_set(option.disable_clipboard) + || !Self::is_bool_option_not_set(option.enable_file_transfer) + || !Self::is_bool_option_not_set(option.disable_keyboard) + || !Self::is_bool_option_not_set(option.follow_remote_cursor) + || !Self::is_bool_option_not_set(option.follow_remote_window) + || !Self::is_bool_option_not_set(option.disable_camera) + || !Self::is_bool_option_not_set(option.show_my_cursor) + } + + fn option_has_any_field(option: &OptionMessage) -> bool { + Self::option_has_non_terminal_login_field(option) + || !Self::is_bool_option_not_set(option.terminal_persistent) + } + + fn is_bool_option_not_set(option: hbb_common::protobuf::EnumOrUnknown) -> bool { + option.enum_value() == Ok(BoolOption::NotSet) + } + + fn message_family(msg: &Message) -> &'static str { + match msg.union.as_ref() { + Some(message::Union::MouseEvent(_)) => "mouse_event", + Some(message::Union::AudioFrame(_)) => "audio_frame", + Some(message::Union::PointerDeviceEvent(_)) => "pointer_device_event", + Some(message::Union::KeyEvent(_)) => "key_event", + Some(message::Union::Clipboard(_)) => "clipboard", + Some(message::Union::FileAction(_)) => "file_action", + Some(message::Union::FileResponse(_)) => "file_response", + Some(message::Union::VoiceCallRequest(_)) => "voice_call_request", + Some(message::Union::VoiceCallResponse(_)) => "voice_call_response", + Some(message::Union::MultiClipboards(_)) => "multi_clipboards", + Some(message::Union::ScreenshotRequest(_)) => "screenshot_request", + Some(message::Union::ScreenshotResponse(_)) => "screenshot_response", + Some(message::Union::TerminalAction(_)) => "terminal_action", + Some(message::Union::TerminalResponse(_)) => "terminal_response", + Some(message::Union::Misc(misc)) => Self::misc_message_family(misc), + Some(_) => "message.other", + None => "empty", + } + } + + fn misc_message_family(misc: &Misc) -> &'static str { + match misc.union.as_ref() { + Some(misc::Union::ChatMessage(_)) => "misc.chat_message", + Some(misc::Union::SwitchDisplay(_)) => "misc.switch_display", + Some(misc::Union::Option(_)) => "misc.option", + Some(misc::Union::AudioFormat(_)) => "misc.audio_format", + Some(misc::Union::CaptureDisplays(_)) => "misc.capture_displays", + Some(misc::Union::ClientRecordStatus(_)) => "misc.client_record_status", + Some(misc::Union::TogglePrivacyMode(_)) => "misc.toggle_privacy_mode", + Some(misc::Union::ToggleVirtualDisplay(_)) => "misc.toggle_virtual_display", + Some(misc::Union::SelectedSid(_)) => "misc.selected_sid", + Some(misc::Union::ChangeResolution(_)) => "misc.change_resolution", + Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution", + Some(misc::Union::MessageQuery(_)) => "misc.message_query", + Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display", + Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request", + Some(_) => "misc.other", + None => "misc.empty", + } + } + #[cfg(feature = "unix-file-copy-paste")] async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) { let is_stopping_allowed = clip.is_stopping_allowed(); @@ -5270,23 +6101,40 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) { +pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - uuids.insert(id, (tokio::time::Instant::now(), uuid)); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + if uuids.get(&id).map(|(_, stored_uuid, _)| stored_uuid) == Some(&uuid) { + return false; + } + uuids.insert(id, (tokio::time::Instant::now(), uuid, false)); + true } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { +pub fn has_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) { - uuids.remove(id); - true - } else { - false + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + uuids + .get(id) + .map(|(_, stored_uuid, claimed)| stored_uuid == uuid && !*claimed) + == Some(true) +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn claim_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + // Keep claimed entries until expiry so replaying a request cannot launch another connection. + if let Some((_, stored_uuid, claimed)) = uuids.get_mut(id) { + if stored_uuid == uuid && !*claimed { + *claimed = true; + return true; + } } + false } #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -5330,20 +6178,20 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] if headless_cm { - let mut username = linux_desktop_manager::get_username(); + let mut username = linux_desktop_manager::get_cached_username(); loop { if !username.is_empty() { break; } // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. - // TODO: - // When `_rx_desktop_ready` is closed, `recv()` returns - // `None` immediately and this loop may spin if `username` remains empty. - // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by - // breaking/returning to avoid hot-looping. - let _res = timeout(1_000, _rx_desktop_ready.recv()).await; - username = linux_desktop_manager::get_username(); + let wait_result = timeout(1_000, _rx_desktop_ready.recv()).await; + if matches!(wait_result, Ok(None)) { + return Err(anyhow!( + "Desktop-ready channel closed before a Linux session became available" + )); + } + username = linux_desktop_manager::get_cached_username(); } let uid = { let username_for_cmd = username.clone(); @@ -5531,6 +6379,8 @@ pub enum AlarmAuditType { ExceedIPv6PrefixAttempts = 6, TerminalOsLoginBackoff = 7, TerminalOsLoginConcurrency = 8, + SessionScopeViolation = 9, + IdWhitelist = 10, } pub enum FileAuditType { @@ -5759,10 +6609,30 @@ impl Drop for Connection { } } +// Login requests are unauthenticated here, so only one may reach loginctl/PAM at a time. +#[cfg(target_os = "linux")] +static LINUX_DESKTOP_START_IN_FLIGHT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "linux")] +struct LinuxDesktopStartGuard; + +#[cfg(target_os = "linux")] +impl Drop for LinuxDesktopStartGuard { + fn drop(&mut self) { + LINUX_DESKTOP_START_IN_FLIGHT.store(false, Ordering::Release); + } +} + +#[cfg(target_os = "linux")] +enum LinuxDesktopStartOutcome { + Finished(String), + Busy, +} + #[cfg(target_os = "linux")] struct LinuxHeadlessHandle { pub is_headless_allowed: bool, - pub is_headless: bool, pub wait_ipc_timeout: u64, pub rx_cm_stream_ready: mpsc::Receiver<()>, pub tx_desktop_ready: mpsc::Sender<()>, @@ -5772,31 +6642,45 @@ struct LinuxHeadlessHandle { impl LinuxHeadlessHandle { pub fn new(rx_cm_stream_ready: mpsc::Receiver<()>, tx_desktop_ready: mpsc::Sender<()>) -> Self { let is_headless_allowed = crate::is_server() && crate::platform::is_headless_allowed(); - let is_headless = is_headless_allowed && linux_desktop_manager::is_headless(); Self { is_headless_allowed, - is_headless, wait_ipc_timeout: 10_000, rx_cm_stream_ready, tx_desktop_ready, } } - pub fn try_start_desktop(&mut self, os_login: Option<&OSLogin>) -> String { - if self.is_headless_allowed { - match os_login { - Some(os_login) => { - linux_desktop_manager::try_start_desktop(&os_login.username, &os_login.password) - } - None => linux_desktop_manager::try_start_desktop("", ""), - } - } else { - "".to_string() + pub async fn try_start_desktop( + &mut self, + os_login: Option<&OSLogin>, + ) -> LinuxDesktopStartOutcome { + let Some((username, password)) = + linux_desktop_start_credentials(self.is_headless_allowed, os_login) + else { + return LinuxDesktopStartOutcome::Finished(String::new()); + }; + if LINUX_DESKTOP_START_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return LinuxDesktopStartOutcome::Busy; } + let guard = LinuxDesktopStartGuard; + let err_msg = match tokio::task::spawn_blocking(move || { + let _guard = guard; + linux_desktop_manager::try_start_desktop(&username, &password) + }) + .await + { + Ok(err_msg) => err_msg, + Err(err) => { + log::error!("Linux desktop start task failed: {err}"); + crate::client::LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() + } + }; + LinuxDesktopStartOutcome::Finished(err_msg) } pub async fn wait_desktop_cm_ready(&mut self) { - if self.is_headless { + // A value captured at construction can lag behind a seat0 transition. + if self.is_headless_allowed && linux_desktop_manager::is_headless() { self.tx_desktop_ready.send(()).await.ok(); let _res = timeout(self.wait_ipc_timeout, self.rx_cm_stream_ready.recv()).await; } @@ -6148,10 +7032,293 @@ mod raii { } } +// An empty whitelist allows everyone. +// +// A peer connecting across servers reports `@` (see +// `create_login_msg`), so the bare id is matched as well. That suffix is self-asserted and +// unsigned, so matching only the full form would reject the honest cross-server peer while +// an attacker just reports the bare id: it can produce false rejects but no true ones. +fn id_whitelist_allows(id_whitelist: &[String], my_id: &str) -> bool { + if id_whitelist.is_empty() { + return true; + } + let bare_id = my_id.split('@').next().unwrap_or(my_id); + id_whitelist + .iter() + .any(|x| wildcard_match(x, my_id) || wildcard_match(x, bare_id)) +} + +// Drop `keys` whose last failure (`.0`, in minutes) is at least `window` old. A backwards +// clock gives a negative age and keeps the entry, so it never widens access. +fn decay_stale_failures( + failures: &mut HashMap, + keys: &[String], + now: i32, + window: i32, +) { + for key in keys { + if failures + .get(key) + .is_some_and(|v| now.saturating_sub(v.0) >= window) + { + failures.remove(key); + } + } +} + +// Unconditionally forget `keys`, unlike `update_failure`'s remove path which requires the +// per-address entry to exist. +fn clear_failures(failures: &mut HashMap, keys: &[String]) { + for key in keys { + failures.remove(key); + } +} + +// Simple glob matching for the ID whitelist: '*' matches any sequence of characters +// (including the empty one), '?' matches exactly one character. Case-insensitive. +fn wildcard_match(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.trim().to_lowercase().chars().collect(); + let t: Vec = text.trim().to_lowercase().chars().collect(); + let (mut pi, mut ti) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while ti < t.len() { + if pi < p.len() && p[pi] == '*' { + star = Some((pi + 1, ti)); + pi += 1; + } else if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if let Some((sp, st)) = star { + pi = sp; + ti = st + 1; + star = Some((sp, st + 1)); + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +#[cfg(test)] mod test { #[allow(unused)] use super::*; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[test] + fn test_pending_switch_sides_uuid_is_claimed_once() { + let id = uuid::Uuid::new_v4().to_string(); + let uuid = uuid::Uuid::new_v4(); + let other_uuid = uuid::Uuid::new_v4(); + assert!(insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + + assert!(!insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + assert!(has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(!claim_pending_switch_sides_uuid("other-peer", &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!insert_pending_switch_sides_uuid(id, uuid)); + } + + #[test] + fn login_scope_latches_session_scope_across_login_retries() { + let port_forward = |host: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_port_forward(PortForward { + host: host.to_owned(), + port: 3389, + ..Default::default() + }); + lr + }; + let first = port_forward("localhost"); + let scope = |lr: &LoginRequest| Connection::login_scope_digest(lr); + + // A retry may carry new credentials, profile data, options, and unknown fields. + let mut retry = port_forward("localhost"); + retry.password = "secret".into(); + retry.hwid = "hwid".into(); + retry.os_login = Some(OSLogin { + username: "admin".to_owned(), + ..Default::default() + }) + .into(); + retry.my_name = "New Display Name".to_owned(); + retry.avatar = "data:image/png;base64,AAAA".to_owned(); + retry + .special_fields + .mut_unknown_fields() + .add_varint(9999, 1); + assert_eq!(scope(&first), scope(&retry)); + + // It may not change the controller identity, move the target, or switch type. + let mut rotated_id = first.clone(); + rotated_id.my_id = "rotated-id".to_owned(); + assert_ne!(scope(&first), scope(&rotated_id)); + assert_ne!(scope(&first), scope(&port_forward("10.0.0.5"))); + let mut moved_port = port_forward("localhost"); + moved_port.mut_port_forward().port = 22; + assert_ne!(scope(&first), scope(&moved_port)); + let terminal = |service_id: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_terminal(Terminal { + service_id: service_id.to_owned(), + ..Default::default() + }); + lr + }; + assert_ne!(scope(&first), scope(&terminal(""))); + assert_ne!(scope(&terminal("a")), scope(&terminal("b"))); + } + + #[test] + fn test_wildcard_match() { + // Exact match. + assert!(wildcard_match("123456789", "123456789")); + assert!(!wildcard_match("123456789", "123456780")); + assert!(!wildcard_match("12345678", "123456789")); + assert!(!wildcard_match("123456789", "12345678")); + // Case-insensitive. + assert!(wildcard_match("MyCustomId", "mycustomid")); + // '*' matches any sequence. + assert!(wildcard_match("*", "123456789")); + assert!(wildcard_match("*", "")); + assert!(wildcard_match("*", "*abc")); + assert!(wildcard_match("123*", "123456789")); + assert!(wildcard_match("123*", "123")); + assert!(wildcard_match("12*", "12*9")); + assert!(!wildcard_match("123*", "124456789")); + assert!(wildcard_match("*789", "123456789")); + assert!(wildcard_match("1*9", "123456789")); + assert!(wildcard_match("1*4*9", "123456789")); + assert!(!wildcard_match("1*4*9", "123456780")); + assert!(wildcard_match("*456*", "123456789")); + // '?' matches exactly one character. + assert!(wildcard_match("12345678?", "123456789")); + assert!(!wildcard_match("123456789?", "123456789")); + assert!(wildcard_match("???456???", "123456789")); + assert!(wildcard_match("1?3*7?9", "123456789")); + // Whitespace around entries is ignored. + assert!(wildcard_match(" 123456789 ", "123456789")); + } + + #[test] + fn test_decay_stale_failures() { + let entry = |minute: i32| (minute, 1, 40); + let keys = ["ip".to_string(), "p64".to_string(), "absent".to_string()]; + let mut m: HashMap = HashMap::new(); + m.insert("ip".to_string(), entry(100)); + m.insert("p64".to_string(), entry(160)); + m.insert("untouched".to_string(), entry(100)); + + // Exactly at the window: forgotten. Still inside it: kept. + decay_stale_failures(&mut m, &keys, 160, 60); + assert!(!m.contains_key("ip")); + assert!(m.contains_key("p64")); + // Keys that were not passed in are never visited, absent ones are a no-op. + assert!(m.contains_key("untouched")); + + // One minute short of the window keeps the entry. + decay_stale_failures(&mut m, &keys, 219, 60); + assert!(m.contains_key("p64")); + decay_stale_failures(&mut m, &keys, 220, 60); + assert!(!m.contains_key("p64")); + + // A clock that jumped backwards must not drop anything. + m.insert("ip".to_string(), entry(500)); + decay_stale_failures(&mut m, &keys, 0, 60); + assert!(m.contains_key("ip")); + } + + #[test] + fn test_clear_failures_drops_shared_prefixes() { + // On IPv6 a whitelisted peer usually has no entry of its own, while the shared + // prefixes that block it do. Clearing must not depend on the per-address entry. + let mut m: HashMap = HashMap::new(); + m.insert("p64".to_string(), (100, 1, 55)); + m.insert("p56".to_string(), (100, 1, 75)); + m.insert("p48".to_string(), (100, 1, 95)); + m.insert("someone-else".to_string(), (100, 1, 95)); + let keys = ["ip", "p64", "p56", "p48"].map(|k| k.to_string()); + + clear_failures(&mut m, &keys); + + for key in ["p64", "p56", "p48"] { + assert!(!m.contains_key(key), "{key} should have been cleared"); + } + // Keys belonging to other peers are left alone. + assert!(m.contains_key("someone-else")); + } + + #[test] + fn test_id_whitelist_allows() { + let list = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + + // An empty whitelist allows everyone. + assert!(id_whitelist_allows(&[], "123456789")); + + // Same server: the peer reports a bare id. + assert!(id_whitelist_allows(&list(&["123456789"]), "123456789")); + assert!(!id_whitelist_allows(&list(&["123456789"]), "987654321")); + + // Cross server: the peer appends its own server, which must not reject it. + assert!(id_whitelist_allows( + &list(&["123456789"]), + "123456789@example.com:21116" + )); + // Cross server from web, whose server is a WebSocket URI. + assert!(id_whitelist_allows( + &list(&["123456789"]), + "123456789@wss://example.com:21118/ws/id" + )); + // A different id is still rejected, suffix or not. + assert!(!id_whitelist_allows( + &list(&["123456789"]), + "987654321@example.com:21116" + )); + + // An entry pinned to one server keeps matching that exact form. + assert!(id_whitelist_allows( + &list(&["123456789@example.com:21116"]), + "123456789@example.com:21116" + )); + assert!(!id_whitelist_allows( + &list(&["123456789@example.com:21116"]), + "123456789@other.com:21116" + )); + // ... and no longer matches the bare id, which is the point of pinning. + assert!(!id_whitelist_allows( + &list(&["123456789@example.com:21116"]), + "123456789" + )); + + // Wildcards keep working on both forms. + assert!(id_whitelist_allows(&list(&["abc*"]), "abcdef")); + assert!(id_whitelist_allows( + &list(&["abc*"]), + "abcdef@example.com:21116" + )); + assert!(id_whitelist_allows( + &list(&["*"]), + "123456789@example.com:21116" + )); + + // Any entry of the list is enough. + assert!(id_whitelist_allows( + &list(&["111111111", "123456789", "222222222"]), + "123456789@example.com:21116" + )); + } + #[cfg(target_os = "macos")] #[test] fn retina() { @@ -6190,4 +7357,345 @@ mod test { assert!(Ipv6Addr::from_str("127.0.0.1").is_err()); assert!(Ipv6Addr::from_str("0").is_err()); } + + fn msg(set: impl FnOnce(&mut Message)) -> Message { + let mut msg = Message::new(); + set(&mut msg); + msg + } + + fn misc_msg(set: impl FnOnce(&mut Misc)) -> Message { + msg(|msg| { + let mut misc = Misc::new(); + set(&mut misc); + msg.set_misc(misc); + }) + } + + fn option_msg(set: impl FnOnce(&mut OptionMessage)) -> Message { + misc_msg(|misc| { + let mut option = OptionMessage::new(); + set(&mut option); + misc.set_option(option); + }) + } + + fn set_supported_decoding(option: &mut OptionMessage) { + option.supported_decoding = hbb_common::protobuf::MessageField::some(Default::default()); + } + + fn assert_scopes( + conn_type: AuthConnType, + cases: impl IntoIterator)>, + ) { + for (msg, expected) in cases { + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &msg), + expected + ); + } + } + + #[test] + fn session_scope_allows_only_messages_for_authenticated_session_type() { + let cases = [ + ( + AuthConnType::FileTransfer, + vec![ + (msg(|m| m.set_file_action(FileAction::new())), None), + (msg(|m| m.set_file_response(FileResponse::new())), None), + (msg(|m| m.set_login_request(LoginRequest::new())), None), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + ( + misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())), + Some("misc.capture_displays"), + ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), + (msg(|m| m.set_clipboard(Clipboard::new())), None), + ( + msg(|m| m.set_multi_clipboards(MultiClipboards::new())), + None, + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()); + o.disable_audio = BoolOption::Yes.into(); + }), + Some("misc.option"), + ), + ], + ), + ( + AuthConnType::Terminal, + vec![ + (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + option_msg(|o| o.terminal_persistent = BoolOption::Yes.into()), + None, + ), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + ( + msg(|m| m.set_file_action(FileAction::new())), + Some("file_action"), + ), + ( + misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), + Some("misc.toggle_privacy_mode"), + ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), + (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), + (msg(|m| m.set_clipboard(Clipboard::new())), None), + ( + msg(|m| m.set_multi_clipboards(MultiClipboards::new())), + None, + ), + ( + misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())), + Some("misc.toggle_virtual_display"), + ), + ( + misc_msg(|m| m.set_change_resolution(Resolution::new())), + Some("misc.change_resolution"), + ), + ( + misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())), + Some("misc.change_display_resolution"), + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()); + o.disable_audio = BoolOption::Yes.into(); + }), + Some("misc.option"), + ), + ], + ), + ( + AuthConnType::ViewCamera, + vec![ + ( + misc_msg(|m| m.set_switch_display(SwitchDisplay::new())), + None, + ), + (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), + ( + msg(|m| m.set_voice_call_request(VoiceCallRequest::new())), + None, + ), + (msg(|m| m.set_audio_frame(AudioFrame::new())), None), + ( + option_msg(|o| o.image_quality = ImageQuality::Balanced.into()), + None, + ), + ( + misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), + None, + ), + ( + misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())), + None, + ), + ( + misc_msg(|m| m.set_change_resolution(Resolution::new())), + None, + ), + ( + misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())), + None, + ), + (msg(|m| m.set_mouse_event(MouseEvent::new())), None), + ( + msg(|m| m.set_pointer_device_event(PointerDeviceEvent::new())), + None, + ), + (msg(|m| m.set_key_event(KeyEvent::new())), None), + (misc_msg(|m| m.set_client_record_status(true)), None), + ( + msg(|m| m.set_file_response(FileResponse::new())), + Some("file_response"), + ), + ( + msg(|m| m.set_terminal_action(TerminalAction::new())), + Some("terminal_action"), + ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), + ], + ), + ( + AuthConnType::Remote, + vec![ + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + None, + ), + (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + None, + ), + ], + ), + ( + AuthConnType::PortForward, + vec![ + (msg(|m| m.set_test_delay(TestDelay::new())), None), + (misc_msg(|m| m.set_close_reason("closed".to_owned())), None), + ( + msg(|m| m.set_file_action(FileAction::new())), + Some("file_action"), + ), + ( + msg(|m| m.set_terminal_action(TerminalAction::new())), + Some("terminal_action"), + ), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ], + ), + ]; + + for (conn_type, messages) in cases { + assert_scopes(conn_type, messages); + } + } + + #[test] + fn session_scope_login_options_are_limited_to_authenticated_session_type() { + let mut option = OptionMessage::new(); + option.image_quality = ImageQuality::Balanced.into(); + option.disable_audio = BoolOption::Yes.into(); + option.block_input = BoolOption::Yes.into(); + option.privacy_mode = BoolOption::Yes.into(); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::ViewCamera, &option); + let scoped = scoped.unwrap(); + assert_eq!(violation, Some("login.option")); + assert_eq!( + scoped.image_quality.enum_value(), + Ok(ImageQuality::Balanced) + ); + assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes)); + assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet)); + assert_eq!(scoped.privacy_mode.enum_value(), Ok(BoolOption::NotSet)); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::FileTransfer, &option); + assert!(scoped.is_none()); + assert_eq!(violation, Some("login.option")); + } + + #[test] + fn session_scope_limited_render_noop_options_reject_mixed_fields() { + for conn_type in [ + AuthConnType::FileTransfer, + AuthConnType::Terminal, + AuthConnType::PortForward, + ] { + let supported_decoding_only = option_msg(set_supported_decoding); + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &supported_decoding_only), + None + ); + + let mixed_option = option_msg(|o| { + set_supported_decoding(o); + o.disable_audio = BoolOption::Yes.into(); + }); + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &mixed_option), + Some("misc.option") + ); + } + } + + #[test] + fn session_scope_view_camera_options_keep_only_camera_fields() { + let mut option = OptionMessage::new(); + option.image_quality = ImageQuality::Balanced.into(); + option.custom_image_quality = 80; + option.custom_fps = 24; + set_supported_decoding(&mut option); + option.disable_audio = BoolOption::Yes.into(); + option.block_input = BoolOption::Yes.into(); + option.disable_clipboard = BoolOption::Yes.into(); + option.enable_file_transfer = BoolOption::Yes.into(); + option.terminal_persistent = BoolOption::Yes.into(); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::ViewCamera, &option); + let scoped = scoped.unwrap(); + assert_eq!(violation, Some("login.option")); + assert_eq!( + scoped.image_quality.enum_value(), + Ok(ImageQuality::Balanced) + ); + assert_eq!(scoped.custom_image_quality, 80); + assert_eq!(scoped.custom_fps, 24); + assert!(scoped.supported_decoding.is_some()); + assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes)); + assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet)); + assert_eq!( + scoped.disable_clipboard.enum_value(), + Ok(BoolOption::NotSet) + ); + assert_eq!( + scoped.enable_file_transfer.enum_value(), + Ok(BoolOption::NotSet) + ); + assert_eq!( + scoped.terminal_persistent.enum_value(), + Ok(BoolOption::NotSet) + ); + } } diff --git a/src/server/display_service.rs b/src/server/display_service.rs index fe3621f26a8..7572caf10cf 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -25,12 +25,159 @@ struct ChangedResolution { lazy_static::lazy_static! { static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported(); static ref CHANGED_RESOLUTIONS: Arc>> = Default::default(); - // Initial primary display index. - // It should not be updated when displays changed. - pub static ref PRIMARY_DISPLAY_IDX: usize = get_primary(); static ref SYNC_DISPLAYS: Arc> = Default::default(); } +#[cfg(target_os = "linux")] +lazy_static::lazy_static! { + static ref WAYLAND_UINPUT_RECT: Mutex = Default::default(); + static ref WAYLAND_LAYOUT: Mutex = Default::default(); +} + +#[cfg(target_os = "linux")] +const WAYLAND_LAYOUT_CHECK_INTERVAL: Duration = Duration::from_millis(1500); + +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandUinputRect { + rect: Option<(i32, i32, i32, i32)>, + last_check: Option, +} + +// Per-display layout used to correct injected coordinates when the compositor moves a +// monitor mid-session. The client keeps sending coordinates offset by the layout it was +// told at session init (`baseline`); we remap them onto the current layout (`live`). +// https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandLayout { + baseline: Vec, + live: Vec, +} + +// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic: +// the common (no-drift) case never touches the layout mutex. +#[cfg(target_os = "linux")] +static WAYLAND_LAYOUT_DRIFTED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); +} + +// The uinput ABS range currently programmed into the device, for the DRM path's "reapply only when +// it changed" check. The PipeWire path compares it inline in refresh_wayland_uinput_rect_if_changed. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> { + WAYLAND_UINPUT_RECT.lock().unwrap().rect +} + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_layout_baseline(baseline: Vec) { + WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); + let mut lock = WAYLAND_LAYOUT.lock().unwrap(); + lock.baseline = baseline; + lock.live.clear(); +} + +// Remap an injected coordinate onto the live compositor layout when it has drifted from +// what the client was told at session init. Lock-free no-op otherwise. +#[cfg(target_os = "linux")] +pub(super) fn remap_wayland_uinput_coord(x: i32, y: i32) -> (i32, i32) { + if !WAYLAND_LAYOUT_DRIFTED.load(Ordering::Relaxed) { + return (x, y); + } + let lock = WAYLAND_LAYOUT.lock().unwrap(); + scrap::wayland::display::remap_to_live_layout(x, y, &lock.baseline, &lock.live) +} + +// The uinput absolute range is set when the session inits. If the compositor layout +// changes afterwards (monitor scale/position change, or a portal virtual output +// appearing once the capture starts), injected coordinates get rescaled by the stale +// range and land offset, https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +fn refresh_wayland_uinput_rect_if_changed() { + if is_x11() || !crate::input_service::wayland_use_uinput() { + return; + } + // Nothing to poll at a login screen; the DRM path owns the rect there. + #[cfg(feature = "drm")] + if crate::platform::linux::is_login_screen_wayland_cached() { + return; + } + { + let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); + if let Some(last_check) = lock.last_check { + if last_check.elapsed() < WAYLAND_LAYOUT_CHECK_INTERVAL { + return; + } + } + lock.last_check = Some(std::time::Instant::now()); + } + let Some((rect, live_rects)) = scrap::wayland::display::get_layout_for_uinput_live() else { + return; + }; + // Refresh the per-display layout every poll: monitor origins can shift (e.g. two + // displays swap positions) without changing the overall desktop rect, and the mouse + // path needs the current per-display geometry to correct coordinates. + let drifted = { + let mut layout = WAYLAND_LAYOUT.lock().unwrap(); + let drifted = !layout.baseline.is_empty() + && !live_rects.is_empty() + && layout.baseline != live_rects; + layout.live = live_rects; + drifted + }; + // The remap corrects for per-display origin shifts; the uinput ABS range corrects for + // the overall bounding box. Only enable the remap once the range matches the live + // layout, otherwise moves would be remapped into a range the device is not yet using. + // A drift with no bbox change (origins swapped) needs no range update and enables now. + let mut range_ok = WAYLAND_UINPUT_RECT.lock().unwrap().rect == Some(rect); + if !range_ok { + let (minx, maxx, miny, maxy) = rect; + log::info!( + "desktop layout changed, update mouse resolution: ({}, {}), ({}, {})", + minx, + maxx, + miny, + maxy + ); + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => { + // Bound the IPC wait, this runs on the display service loop and + // `set_resolution()` has no timeout on the response read. + // timeout must be built inside the runtime, or it panics + // "there is no reactor running". See clipboard_service.rs. + match rt.block_on(async { + timeout( + 3_000, + crate::input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + }) { + // Record the rect only after a successful apply, so a transient + // failure is retried on the next check. + Ok(Ok(())) => { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); + range_ok = true; + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } + } + Err(err) => { + log::error!("Failed to build tokio runtime: {}", err); + } + } + } + // Publish the flag last: a `true` read is always backed by a current `live` and a + // matching uinput range. A failed range apply leaves this false and retries next poll. + WAYLAND_LAYOUT_DRIFTED.store(drifted && range_ok, Ordering::Relaxed); +} + // https://github.com/rustdesk/rustdesk/pull/8537 static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false); @@ -41,22 +188,14 @@ struct SyncDisplaysInfo { } impl SyncDisplaysInfo { - fn check_changed(&mut self, displays: Vec) { - if self.displays.len() != displays.len() { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } + fn check_changed(&mut self, displays: &[DisplayInfo]) { + if self.displays.as_slice() == displays { return; } - for (i, d) in displays.iter().enumerate() { - if d != &self.displays[i] { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } - return; - } + + self.displays = displays.to_vec(); + if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { + self.is_synced = false; } } @@ -201,6 +340,16 @@ fn check_get_displays_changed_msg() -> Option { #[cfg(target_os = "linux")] { if !is_x11() { + // On the DRM/KMS capture path the PipeWire enumeration (which is what feeds + // `SYNC_DISPLAYS` via `check_update_displays`) is bypassed, so populate the sync list + // from the DRM display list here. Without this the display service broadcasts an empty + // list that overwrites the login peer-info displays and the client shows "No displays". + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } return get_displays_msg(); } } @@ -242,6 +391,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { sp.send(msg_out); log::info!("Displays changed"); } + + #[cfg(target_os = "linux")] + if sp.has_subscribes() { + refresh_wayland_uinput_rect_if_changed(); + } + std::thread::sleep(Duration::from_millis(300)); } @@ -301,14 +456,63 @@ pub(super) fn get_display_info(idx: usize) -> Option { SYNC_DISPLAYS.lock().unwrap().displays.get(idx).cloned() } +// True when at least one advertised (synced) display is NOT served by the DRM/KMS capture path, +// i.e. a mixed DRM + PipeWire session. The cursor service (platform::linux::get_cursor / +// get_cursor_data) uses this to decide whether a hidden DRM hardware-cursor sentinel is +// authoritative: in a pure-DRM session it is (the pointer is genuinely off every captured CRTC), +// but in a mixed session the sentinel only means the pointer moved onto a PipeWire-served display, +// whose cursor must come from the normal path instead of being hidden everywhere. +// +// When DRM capture is active the advertised list is enumerated from the DRM display list, so a DRM +// list shorter than the synced list means at least one advertised display is served by PipeWire. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub fn has_non_drm_backed_display() -> bool { + match super::drm_capturer::display_count_and_any_demoted() { + // A display served by PipeWire is either ABSENT from the DRM list (a shorter count, e.g. a + // pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked + // offline so the index space stays aligned -- see get_display_infos). The count check alone + // misses the demotion case (same count), so a demoted display is treated as non-DRM-backed + // too. This is what gates the hidden-cursor sentinel: it stays authoritative only in a + // pure-DRM session. The scalar accessor is deliberate: this is polled every cursor tick + // while the sentinel is active, and cloning + geometry-augmenting the whole list per tick + // (what get_display_infos does) answered the same two facts. + Some((count, any_demoted)) => { + count < SYNC_DISPLAYS.lock().unwrap().displays.len() || any_demoted + } + None => false, + } +} + // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { + let _ = update_sync_displays(all); +} + +/// Whether there is a compositor on this seat worth asking. `get_displays()` does not cache +/// its failure, so where there is none it re-probes every call for an answer that cannot +/// change any caller's outcome. Last in the `&&` chain, so it never runs first on a poll. +#[inline] +#[cfg(target_os = "linux")] +fn wayland_has_compositor() -> bool { + #[cfg(feature = "drm")] + { + !crate::platform::linux::is_login_screen_wayland_cached() + } + #[cfg(not(feature = "drm"))] + { + true + } +} + +// Return the converted input snapshot while updating the shared display cache. +pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. // If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale(). #[cfg(target_os = "linux")] let use_logical_scale = !is_x11() && crate::is_server() + && wayland_has_compositor() && scrap::wayland::display::get_displays().displays.len() > 1; let displays = all .iter() @@ -346,7 +550,8 @@ pub(super) fn check_update_displays(all: &Vec) { } }) .collect::>(); - SYNC_DISPLAYS.lock().unwrap().check_changed(displays); + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + displays } pub fn is_inited_msg() -> Option { @@ -357,34 +562,38 @@ pub fn is_inited_msg() -> Option { None } -pub async fn update_get_sync_displays_on_login() -> ResultType> { +// Return the primary index with the refreshed list so login cannot mix display snapshots. +pub async fn update_get_sync_displays_on_login() -> ResultType<(Vec, usize)> { #[cfg(target_os = "linux")] { if !is_x11() { - return super::wayland::get_displays().await; + let (displays, primary_display_idx) = + super::wayland::get_displays_and_primary().await?; + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, displays.len()); + return Ok((displays, primary_display_idx)); } } #[cfg(not(windows))] let displays = display_service::try_get_displays(); #[cfg(windows)] let displays = display_service::try_get_displays_add_amyuni_headless(); - check_update_displays(&displays?); - Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone()) + let displays = displays?; + let primary_display_idx = get_primary_2(&displays); + let sync_displays = update_sync_displays(&displays); + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, sync_displays.len()); + Ok((sync_displays, primary_display_idx)) } #[inline] -pub fn get_primary() -> usize { - #[cfg(target_os = "linux")] - { - if !is_x11() { - return match super::wayland::get_primary() { - Ok(n) => n, - Err(_) => 0, - }; - } +fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize { + // Zero is the protocol fallback when the list is empty or its primary index is stale. + if primary_display_idx < display_len { + primary_display_idx + } else { + 0 } - - try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0) } #[inline] @@ -486,3 +695,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType> } Ok(displays) } + +#[cfg(test)] +mod tests { + use super::normalize_primary_display_idx; + + #[test] + fn normalize_primary_display_idx_bounds() { + assert_eq!(normalize_primary_display_idx(0, 0), 0); + assert_eq!(normalize_primary_display_idx(0, 2), 0); + assert_eq!(normalize_primary_display_idx(1, 2), 1); + assert_eq!(normalize_primary_display_idx(2, 2), 0); + } +} diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs new file mode 100644 index 00000000000..0c6beb49315 --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,1852 @@ +// Unprivileged consumer of the root `--service`'s DRM/KMS capture stream: the service does the +// privileged export (open + grab the scanout dma-buf fd), the EGL detile / RGBA convert runs here. + +use crate::ipc::{connect_drm, Data, DrmDisplayInfo}; +use hbb_common::{anyhow::anyhow, bail, log, message_proto::DisplayInfo, tokio, ResultType}; +use scrap::drm_render::RenderConverter; +use scrap::drmtap_dl::drmtap_dmabuf_desc; +use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer}; +use std::collections::BTreeMap; +use std::io; +use std::os::fd::{AsRawFd, RawFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const HANDSHAKE_TIMEOUT_MS: u64 = 3000; +const DRM_CONNECT_TIMEOUT_MS: u64 = 1000; +/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*). +const DISPLAY_LIST_TIMEOUT_MS: u64 = HANDSHAKE_TIMEOUT_MS + 4000; +/// Covers the connect timeout plus `recv_msg_timeout2` applying DISPLAY_LIST_TIMEOUT_MS TWICE +/// (first byte, then body). The render-node open and the DrmStart send can still overrun it. +const HANDSHAKE_WAIT_MS: u64 = DRM_CONNECT_TIMEOUT_MS + DISPLAY_LIST_TIMEOUT_MS * 2 + 500; +/// Only the header read rechecks `stop`, so bound the body read here rather than relying on + /// `next_raw_into`'s own cap. +const BODY_READ_TIMEOUT: Duration = Duration::from_secs(5); + +struct FrameSlot { + // Row stride is `pixels.len() / height`, possibly padded; the format is per frame. + latest: Option<(usize, usize, Pixfmt, Vec)>, + // TWO slots: two buffers can be idle at once -- the receive path takes one and publishes in two + // SEPARATE acquisitions, so the encoder can hand its borrow back in between. + free: [Option>; 2], + ended: Option, +} + +impl FrameSlot { + fn publish(&mut self, w: usize, h: usize, fmt: Pixfmt, buf: Vec) { + if let Some((.., old)) = self.latest.take() { + self.recycle(old); + } + self.latest = Some((w, h, fmt, buf)); + } + + fn recycle(&mut self, buf: Vec) { + if let Some(slot) = self.free.iter_mut().find(|s| s.is_none()) { + *slot = Some(buf); + } + } + + fn take_free(&mut self) -> Option> { + self.free.iter_mut().find_map(|s| s.take()) + } +} + +struct Shared { + slot: Mutex, + cv: Condvar, +} + +pub struct IpcDrmCapturer { + shared: Arc, + stop: Arc, + display: i32, + connector: Option, + // What the encoder was sized from: CapturerInfo{width,height} is read once, at build time. + session_size: Option<(usize, usize)>, + cur: Vec, + cur_w: usize, + cur_h: usize, + cur_fmt: Pixfmt, + got_frame: bool, +} + +/// A list index is NOT an identity: `drm_enumerate_all_displays` concatenates per-card lists. +fn connector_key(d: &DrmDisplayInfo) -> String { + format!("{}:{}", d.device, d.name) +} + +/// Takes DRM_STATE: never call it while holding one of the per-display maps below. +fn display_info_of(display: i32) -> Option { + match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.get(display.max(0) as usize).cloned(), + _ => None, + } +} + +/// A delivered frame resets the streak verdicts (`zero_frame_streak`, `demotes`, `since`) and + /// nothing else. +#[derive(Clone, Copy)] +struct DisplayHealth { + zero_frame_streak: u32, + since: Instant, + demotes: u32, + last_build: Option, + rapid_builds: u32, + /// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node + /// is not the GPU that exported the scanout. Follows the monitor for the process run. + prefer_cpu: bool, +} + +impl DisplayHealth { + fn new() -> Self { + Self { + zero_frame_streak: 0, + since: Instant::now(), + demotes: 0, + last_build: None, + rapid_builds: 0, + prefer_cpu: false, + } + } + + fn demoted(&self) -> bool { + self.zero_frame_streak >= DRM_GRAB_MAX_FAILURES + && self.since.elapsed() < demote_cooldown(self.demotes) + } +} + +static DRM_DISPLAY_HEALTH: Mutex> = Mutex::new(BTreeMap::new()); +const DRM_GRAB_MAX_FAILURES: u32 = 4; +const DEMOTE_COOLDOWN: Duration = Duration::from_secs(30); +const DEMOTE_BACKOFF_MAX_SHIFT: u32 = 4; +const RAPID_REBUILD_WINDOW: Duration = Duration::from_secs(3); +const RAPID_REBUILD_MAX: u32 = 6; + +/// Doubling per demotion up to `DEMOTE_BACKOFF_MAX_SHIFT`; a delivered frame zeroes the demote +/// count (see `frame()`), not decayed by time. +fn demote_cooldown(demotes: u32) -> Duration { + DEMOTE_COOLDOWN * (1u32 << demotes.saturating_sub(1).min(DEMOTE_BACKOFF_MAX_SHIFT)) +} + +#[derive(Debug, PartialEq, Eq)] +enum RefreshOutcome { + Publish, + Unavailable, + Restamp, + /// The evidence is about the PRODUCER, not the hardware: give the verdict up to `Unknown`. + GiveUp, +} + +/// `failures` counts consecutive failures INCLUDING this one, so it is 1 on the first. +fn refresh_outcome(probe: Option, failures: u32) -> RefreshOutcome { + match probe { + Some(0) => RefreshOutcome::Unavailable, + Some(_) => RefreshOutcome::Publish, + None if failures >= DRM_REFRESH_MAX_FAILURES => RefreshOutcome::GiveUp, + None => RefreshOutcome::Restamp, + } +} + +fn drm_prefer_cpu(key: &Option) -> bool { + key.as_ref().is_some_and(|k| { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(k) + .is_some_and(|h| h.prefer_cpu) + }) +} + +fn drm_set_prefer_cpu(key: &Option) { + if let Some(k) = key { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .entry(k.clone()) + .or_insert_with(DisplayHealth::new) + .prefer_cpu = true; + } +} + +fn render_node_count() -> usize { + std::fs::read_dir("/dev/dri").map_or(0, |entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix("renderD")) + .and_then(|minor| minor.parse::().ok()) + .is_some() + }) + .count() + }) +} + +static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +impl IpcDrmCapturer { + /// The service resolves indices against ITS OWN enumeration, so the receive thread re-resolves + /// `expected` by connector identity and returns the index geometry must be read at. + pub fn new( + display: i32, + expected: Option, + ) -> ResultType<(IpcDrmCapturer, Vec, usize)> { + let shared = Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }); + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel::, usize)>>(); + { + let shared = shared.clone(); + let stop = stop.clone(); + std::thread::Builder::new() + .name("drm-recv".into()) + .spawn(move || recv_thread(display, expected, shared, stop, tx)) + .map_err(|err| anyhow!("could not spawn the drm receive thread: {err}"))?; + } + let (displays, wire_idx) = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) { + Ok(res) => res?, + Err(_) => { + // A handshake completing later would stream unowned: Drop never runs here. + stop.store(true, Ordering::SeqCst); + bail!("drm capture handshake timed out"); + } + }; + Ok(( + IpcDrmCapturer { + shared, + stop, + display, + connector: displays.get(wire_idx).map(connector_key), + session_size: displays + .get(wire_idx) + .map(|d| (d.width as usize, d.height as usize)), + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + }, + displays, + wire_idx, + )) + } + + /// Without an identity, skip rather than record under "", which get_capturer_info reads back + /// as the same key: one unidentifiable display would demote the next. + fn note_session_without_frame(&self) { + let Some(key) = self.connector.clone() else { + log::debug!( + "drm: display {} produced no frame but has no connector identity; \ + not counting it against any display", + self.display + ); + return; + }; + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.zero_frame_streak += 1; + h.since = Instant::now(); + if h.zero_frame_streak == DRM_GRAB_MAX_FAILURES { + h.demotes += 1; + log::warn!( + "drm: display {} produced no frame in {} sessions; using PipeWire for it, \ + retrying DRM in {:?} (demotion {})", + self.display, + h.zero_frame_streak, + demote_cooldown(h.demotes), + h.demotes + ); + } + } +} + +impl Drop for IpcDrmCapturer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + } +} + +impl TraitCapturer for IpcDrmCapturer { + fn frame<'a>(&'a mut self, timeout: Duration) -> io::Result> { + let deadline = Instant::now() + timeout; + { + let mut slot = self.shared.slot.lock().unwrap(); + loop { + if slot.latest.is_some() || slot.ended.is_some() { + break; + } + let now = Instant::now(); + if now >= deadline { + return Err(io::ErrorKind::WouldBlock.into()); + } + let (guard, _timed_out) = + self.shared.cv.wait_timeout(slot, deadline - now).unwrap(); + slot = guard; + } + if let Some((w, h, fmt, buf)) = slot.latest.take() { + drop(slot); + // convert_to_yuv only refuses a source LARGER than its destination, so a smaller + // frame leaves stale edges on screen. On the FIRST frame nothing changed: the list + // carries the CRTC mode, a frame the scanout fb, different when a CRTC scales. + if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) { + self.shared.slot.lock().unwrap().recycle(buf); + if !self.got_frame { + self.note_session_without_frame(); + } + let (sw, sh) = self.session_size.unwrap_or_default(); + let what = if self.got_frame { + "changed geometry mid-session" + } else { + "never matched its advertised geometry" + }; + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding", + self.display + ), + )); + } + let previous = std::mem::replace(&mut self.cur, buf); + self.shared.slot.lock().unwrap().recycle(previous); + self.cur_w = w; + self.cur_h = h; + self.cur_fmt = fmt; + if !self.got_frame { + // Clear ONLY the streak: `rapid_builds` is for a display that delivers a first + // frame then fails, and `prefer_cpu` is written on the recv thread. + self.got_frame = true; + if let Some(key) = &self.connector { + if let Some(h) = DRM_DISPLAY_HEALTH.lock().unwrap().get_mut(key) { + h.zero_frame_streak = 0; + h.demotes = 0; + h.since = Instant::now(); + } + } + } + } else { + let err = slot + .ended + .clone() + .unwrap_or_else(|| "drm stream ended".to_owned()); + if !self.got_frame { + self.note_session_without_frame(); + } + return Err(io::Error::new(io::ErrorKind::Other, err)); + } + } + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.cur, + self.cur_fmt, + self.cur_w, + self.cur_h, + ))) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn recv_thread( + display: i32, + expected: Option, + shared: Arc, + stop: Arc, + tx: std::sync::mpsc::Sender, usize)>>, +) { + let cursor_epoch = next_cursor_epoch(); + let mut conn = match connect_drm(DRM_CONNECT_TIMEOUT_MS).await { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + let displays = match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => v, + Some(Ok((other, _fd))) => { + let _ = tx.send(Err(anyhow!("expected DrmDisplayList, got {:?}", other))); + return; + } + Some(Err(err)) => { + let _ = tx.send(Err(err)); + return; + } + None => { + let _ = tx.send(Err(anyhow!("timed out waiting for DrmDisplayList"))); + return; + } + }; + // Our monitor's index IN THIS CONNECTION'S LIST; `display` indexes the CLIENT's. Measured on a + // T2: a woken 2880x1800 panel re-enters ahead of the Touch Bar, flipping index 0. + let wire_idx = match &expected { + Some(e) => { + match displays + .iter() + .position(|d| d.device == e.device && d.name == e.name) + { + Some(i) => i, + None => { + let _ = tx.send(Err(anyhow!( + "display {display} ({}) is no longer in the service's list; \ + the video service will rebuild against the fresh topology", + e.name + ))); + return; + } + } + } + None => { + let _ = tx.send(Err(anyhow!( + "display {display} is not in the advertised list; not guessing a monitor for it" + ))); + return; + } + }; + // (device, crtc_id) survives a topology change; list indices do not. + let bound_to = displays + .get(wire_idx) + .map(|d| (d.device.clone(), d.crtc_id)); + let our_key = displays.get(wire_idx).map(connector_key); + let render_node = displays + .get(wire_idx) + .or_else(|| displays.first()) + .map(|d| d.render_node.clone()) + .unwrap_or_default(); + // An unnamed exporter on a multi-render-node host fails SILENTLY: on a Jetson + // (scanout nvidia-drm, first render node tegra) the wrong device's import SUCCEEDS and corrupts + // the pixels, so there is no convert error for prefer_cpu to learn from. + let ambiguous_gpu = render_node.is_empty() && render_node_count() > 1; + let force_cpu = drm_prefer_cpu(&our_key) || ambiguous_gpu; + let mut converter = if force_cpu { + None + } else { + RenderConverter::open_render(Some(render_node.as_str())) + }; + let need_cpu = converter.is_none(); + if need_cpu { + log::info!( + "drm: requesting the CPU-converted frame path for display {display} ({})", + if ambiguous_gpu { + "the service did not name the exporting GPU and this host has several render nodes; \ + auto-selecting one can import the scanout on the wrong device and silently corrupt it" + } else if force_cpu { + "a prior consumer convert failed, e.g. multi-GPU render-node mismatch" + } else { + "no render-node convert context: libdrmtap did not load here, or \ + drmtap_open_render found no usable /dev/dri/renderD*" + } + ); + } + if let Err(err) = conn + .send_msg( + &Data::DrmStart { + display: wire_idx as i32, + need_cpu, + }, + None, + ) + .await + { + let _ = tx.send(Err(err)); + return; + } + let _ = tx.send(Ok((displays, wire_idx))); + + let end_reason = loop { + if stop.load(Ordering::SeqCst) { + break "stopped".to_owned(); + } + let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await { + None => continue, // timeout: re-check stop at the loop top + Some(Ok(pair)) => pair, + Some(Err(err)) => break format!("recv: {err}"), + }; + match msg { + Data::DrmFrameDmabuf(desc) => { + let conv = match converter.as_mut() { + Some(c) => c, + None => break "no DRM render node; cannot convert dma-buf frame".to_owned(), + }; + // Valid in THIS process; -1 is an import-once cache hit on `fb_id`. + let received_fd: RawFd = if desc.has_fd { + match recv_fd.as_ref() { + Some(f) => f.as_raw_fd(), + None => { + break "dma-buf frame set has_fd but carried no SCM_RIGHTS fd".to_owned() + } + } + } else { + -1 + }; + let mut ddesc = drmtap_dmabuf_desc { + dma_buf_fd: -1, + width: desc.width, + height: desc.height, + format: desc.format, + modifier: desc.modifier, + fb_id: desc.fb_id, + // RAW: `drm_render::convert` REJECTS an out-of-range count rather than + // clamping, so the count the C reads is the one that was validated. + num_planes: desc.num_planes, + offsets: desc.offsets, + pitches: desc.pitches, + hdr_eotf: desc.hdr_eotf, + hdr_max_nits: desc.hdr_max_nits, + }; + match conv.convert(&mut ddesc, received_fd) { + Ok((data, w, h, fmt)) => { + // Borrowed from the render context, valid only until the next convert. + // Copy into a recycled buffer, and OUTSIDE the slot lock, so a + // multi-megabyte memcpy never holds the encoder off the slot. + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.extend_from_slice(data); + let mut slot = shared.slot.lock().unwrap(); + slot.publish(w as usize, h as usize, fmt, buf); + shared.cv.notify_one(); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) => { + drm_set_prefer_cpu(&our_key); + break format!("convert: {err}"); + } + } + // `recv_fd` closes at the end of this iteration, AFTER convert imported it. + // Ack so the producer RELEASES ONE SEND CREDIT and forwards the next; this bounds + // the socket to a couple of in-flight frames instead of a stale backlog. + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmFrame { width, height } => { + // `frame()` hands this to PixelBuffer::new, which derives the stride as + // `data.len() / height`: height==0 would DIVIDE BY ZERO. + if width == 0 || height == 0 { + break format!("cpu frame: degenerate geometry {width}x{height}"); + } + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut buf)).await { + Err(_) => break "cpu frame body read timed out".to_owned(), + Ok(Ok(())) => { + if buf.len() < need { + break format!( + "cpu frame: body {} bytes < {need} for {width}x{height}", + buf.len() + ); + } + let mut slot = shared.slot.lock().unwrap(); + slot.publish(width as usize, height as usize, Pixfmt::BGRA, buf); + shared.cv.notify_one(); + } + Ok(Err(err)) => break format!("frame body: {err}"), + } + // Ack this CPU frame too (flow control; see the dma-buf arm above). + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + } => { + // get_cursor_data() hands `colors` straight to the client, which renders + // width*height*4 RGBA bytes: a short body would make it READ PAST THE BUFFER. A + // hidden-cursor sentinel arrives as 1x1 with a 4-byte body, so `need` is 4 and the + // check is live. + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut raw = Vec::new(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut raw)).await { + Err(_) => break "cursor body read timed out".to_owned(), + Ok(Ok(())) => { + if raw.len() < need { + break format!( + "cursor body {} bytes < {need} for {width}x{height}", + raw.len() + ); + } + set_drm_cursor( + display, + cursor_epoch, + DrmCursorData { + id, + width: width as i32, + height: height as i32, + hotx, + hoty, + colors: raw, + }, + ); + } + Ok(Err(err)) => break format!("cursor body: {err}"), + } + } + Data::DrmDisplaysChanged(list) => { + // `display` (the CLIENT's index) and NOT `wire_idx`, deliberately. `bound_to` is an + // identity `(device, crtc_id)`, not a position, so this asks "does that slot still + // name MY monitor"; and the swap below installs this list as DRM_STATE, which is the + // client-space list display_service re-advertises and input is mapped through. + // Probing `wire_idx` stays quiet in exactly the case this guard exists for: a stream + // whose wire_idx differs from display keeps running while the client's index comes to + // mean another monitor. Checked BEFORE the swap, against the topology this stream + // started on. + let now_at_our_index = list + .get(display.max(0) as usize) + .map(|d| (d.device.clone(), d.crtc_id)); + if bound_to.is_some() && now_at_our_index != bound_to { + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + break match (&bound_to, &now_at_our_index) { + (Some((_, was)), Some((_, now))) => format!( + "hotplug renumbered display {display}: it was crtc {was}, now crtc {now}" + ), + _ => format!("hotplug removed display {display} from the list"), + }; + } + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + UINPUT_REFRESH_GEN.fetch_add(1, Ordering::AcqRel); + if !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel) { + // Taken BEFORE the spawn and moved in: `Builder::spawn` can FAIL with EAGAIN after + // the swap, so a guard built inside the closure would never exist and the flag + // would stay set for the PROCESS LIFETIME. + let mut busy = UinputRefreshGuard(true); + let spawned = std::thread::Builder::new() + .name("drm-uinput-refresh".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(err) => { + log::warn!( + "drm: uinput refresh worker could not build a runtime: {err}" + ); + return; // the guard hands the slot back + } + }; + let mut served = 0u64; + loop { + let g = UINPUT_REFRESH_GEN.load(Ordering::Acquire); + if g != served { + served = g; + rt.block_on(super::wayland::update_uinput_resolution()); + continue; + } + busy.release(); + if UINPUT_REFRESH_GEN.load(Ordering::Acquire) == served { + break; + } + if !busy.retake() { + break; // another handler already started a fresh worker + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the uinput refresh worker: {err}"); + } + } + } + _ => {} // ignore any unexpected control message + } + }; + log::info!("drm capture stream ended: {end_reason}"); + // Drop the render context on THIS thread: its EGL state + cached imports are thread-local and + // a cross-thread close strands them. Never in `Drop`, which runs on the encoder thread. + drop(converter); + remove_drm_cursor(display, cursor_epoch); + let mut slot = shared.slot.lock().unwrap(); + slot.ended = Some(format!("drm stream ended ({end_reason})")); + shared.cv.notify_one(); +} + +// Keyed by display index: the cursor lives on whichever CRTC the pointer is over and every other +// stream reports a hidden sentinel, which under a single global would clobber it. +#[derive(Clone)] +pub struct DrmCursorData { + pub id: u64, + pub width: i32, + pub height: i32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +static DRM_CURSOR: Mutex> = Mutex::new(BTreeMap::new()); +// Monotonic per-stream tag: a rebuilt stream reuses the display index, so a torn-down stream drops +// its entry ONLY if the epoch still matches. +static DRM_CURSOR_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_cursor_epoch() -> u64 { + DRM_CURSOR_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +// Compare-and-set: a still-draining predecessor stream (older epoch) must not overwrite the entry a +// replacement stream (newer epoch) already published. Only accept a write whose epoch is at least +// the stored one. +fn set_drm_cursor(display: i32, epoch: u64, c: DrmCursorData) { + let mut map = DRM_CURSOR.lock().unwrap(); + match map.get(&display) { + Some((stored, _)) if *stored > epoch => {} + _ => { + map.insert(display, (epoch, c)); + } + } +} + +fn remove_drm_cursor(display: i32, epoch: u64) { + let mut map = DRM_CURSOR.lock().unwrap(); + if map.get(&display).map(|(e, _)| *e) == Some(epoch) { + map.remove(&display); + } +} + +fn with_drm_cursor(f: impl Fn(&DrmCursorData) -> T) -> Option { + let map = DRM_CURSOR.lock().unwrap(); + map.values() + .map(|(_, c)| c) + .find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID) + .or_else(|| map.values().map(|(_, c)| c).next()) + .map(f) +} + +pub fn drm_cursor_id() -> Option { + with_drm_cursor(|c| c.id) +} + +/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed +/// through as-is, like the XFixes path, so the client sees one cursor format from either backend. +pub fn drm_cursor() -> Option { + with_drm_cursor(|c| c.clone()) +} + +enum ProbeState { + Unknown, + Unavailable(Instant), + Available(Instant, Vec), +} + +static DRM_STATE: Mutex = Mutex::new(ProbeState::Unknown); +const NEGATIVE_TTL: Duration = Duration::from_secs(30); +const POSITIVE_TTL: Duration = Duration::from_secs(15); + +/// Runs on a throwaway thread: a nested `#[tokio::main]` panics if called from inside a runtime. +fn query_displays() -> ResultType> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("drm-query".into()) + .spawn(move || { + let _ = tx.send(query_displays_async()); + }) + .map_err(|err| anyhow!("could not spawn the drm display query thread: {err}"))?; + rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) + .map_err(|_| anyhow!("drm display query timed out"))? +} + +#[tokio::main(flavor = "current_thread")] +async fn query_displays_async() -> ResultType> { + query_displays_inner().await +} + +async fn query_displays_inner() -> ResultType> { + let mut conn = connect_drm(DRM_CONNECT_TIMEOUT_MS).await?; + match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => Ok(v), + Some(Ok((other, _fd))) => Err(anyhow!("expected DrmDisplayList, got {:?}", other)), + Some(Err(err)) => Err(err), + None => Err(anyhow!("timed out waiting for DrmDisplayList")), + } +} + +static DRM_PROBE_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_PROBE_MAX_FAILURES: u32 = 5; +static DRM_REFRESH_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_REFRESH_MAX_FAILURES: u32 = 3; +// Single-flight, so is_available() never calls query_displays() (~4s of IPC) holding DRM_STATE. +static DRM_PROBE_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Advanced by every publish, so a slow UNLOCKED probe can tell a newer verdict landed meanwhile. +static DRM_STATE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// EVERY verdict change to DRM_STATE goes through here so the generation stays truthful; the TTL + /// restamp in `refresh_available_async` is the one direct write. +#[inline] +fn publish_probe_state(st: &mut ProbeState, next: ProbeState) { + *st = next; + DRM_STATE_GEN.fetch_add(1, Ordering::Release); +} + +/// Releases DRM_PROBE_IN_FLIGHT on EVERY exit; a leaked release wedges all future probes. +struct ProbeInFlightGuard; +impl Drop for ProbeInFlightGuard { + fn drop(&mut self) { + DRM_PROBE_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Ownership of `UINPUT_REFRESH_BUSY`, released on every exit. It is handed back and re-taken +/// mid-loop, so releasing on drop unconditionally would clear a flag a REPLACEMENT worker owns. +struct UinputRefreshGuard(bool); +impl UinputRefreshGuard { + fn release(&mut self) { + if self.0 { + self.0 = false; + UINPUT_REFRESH_BUSY.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel); + self.0 + } +} +impl Drop for UinputRefreshGuard { + fn drop(&mut self) { + self.release(); + } +} + +/// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside +/// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". +pub(crate) fn is_available_cached() -> bool { + matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) +} + +/// The three honest answers the availability machinery can give. `Unsettled` — another probe in +/// flight, or a failure still below the disable threshold — is not a verdict, and the +/// login-screen headless decision must not read it as one. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Availability { + Available, + Unavailable, + Unsettled, +} + +/// MAY BLOCK for seconds: never a routing gate, and never on the login request path — that path +/// reads `availability_cached`. This blocking form serves the capture-side callers through +/// `is_available`, where waiting out a settle is acceptable. +fn availability() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + // A settled "no" STAYS the answer while an off-thread re-probe re-verifies it; going + // Unknown at expiry would reopen an Unsettled window every TTL on a helper-less box, and + // the login decision reads Unsettled as a possible greeter. + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, // fall through and probe with the lock released + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + // Someone else is mid-probe: their result is not in yet, and "not yet" is not "no". + return match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(..) => Availability::Available, + ProbeState::Unavailable(_) => Availability::Unavailable, + ProbeState::Unknown => Availability::Unsettled, + }; + } + let _in_flight = ProbeInFlightGuard; + probe_and_publish() +} + +/// The non-blocking tri-state, for decisions on the LOGIN REQUEST path that must never wait: an +/// unauthenticated peer reaches that path, so a probe there would let it park a worker for the +/// probe deadline. Unknown kicks the probe off-thread and answers Unsettled, which the login +/// decision treats as a possibly servable greeter (no Xorg) until the state settles. +pub(crate) fn availability_cached() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if !DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + let in_flight = ProbeInFlightGuard; + let spawned = std::thread::Builder::new() + .name("drm-avail-probe".into()) + .spawn(move || { + let _in_flight = in_flight; + probe_and_publish(); + }); + // On error the guard moved into the dropped closure and released the flag already. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the availability probe thread: {err}"); + } + } + Availability::Unsettled +} + +/// Probe synchronously and publish the outcome. The caller must hold DRM_PROBE_IN_FLIGHT. +fn probe_and_publish() -> Availability { + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let answer = match result { + Ok(list) if !list.is_empty() => { + log::debug!( + "drm: availability probe -> available ({} displays) in {:?}", + list.len(), + t.elapsed() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + Availability::Available + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable + } + Err(err) => { + let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if n >= DRM_PROBE_MAX_FAILURES { + log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable + } else { + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + // Deliberately still Unknown in DRM_STATE: this is a retry window, not a verdict. + Availability::Unsettled + } + } + }; + drop(st); + answer +} + +/// The boolean form for capture-path callers, where an unsettled probe and a definitive "no" +/// route the same way (into the non-DRM fallback). +pub(crate) fn is_available() -> bool { + availability() == Availability::Available +} + +/// The negative mirror of `refresh_available_async`: re-verify a stale Unavailable without ever +/// answering Unknown in the meantime. A failed or empty re-probe re-confirms the "no" with a +/// fresh timestamp; only a non-empty display list flips the verdict. +fn refresh_unavailable_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + match &*st { + ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL => {} + _ => return, + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-unavail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + match result { + Ok(list) if !list.is_empty() => { + log::info!( + "drm: availability re-probe -> available ({} displays)", + list.len() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + _ => { + // Restamp: a failed or empty re-probe is a fresh confirmation of "no". + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + } + }); + // Nothing to release on error: the guard moved into the closure and drops with it either way. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the unavailability re-probe thread: {err}"); + } +} + +fn refresh_available_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-avail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + let failures = match &result { + Ok(_) => { + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + 0 + } + Err(_) => DRM_REFRESH_FAILURES.fetch_add(1, Ordering::Relaxed) + 1, + }; + match refresh_outcome(result.as_ref().ok().map(|l| l.len()), failures) { + RefreshOutcome::Publish => { + let fresh = result.unwrap_or_default(); + let changed = match &*st { + ProbeState::Available(_, old) => *old != fresh, + _ => true, + }; + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), fresh)); + if changed { + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + RefreshOutcome::Unavailable => { + log::info!("drm: refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + // Only the TTL stamp moves, so this does NOT go through publish_probe_state. + RefreshOutcome::Restamp => { + if let ProbeState::Available(since, _) = &mut *st { + *since = Instant::now(); + } + } + RefreshOutcome::GiveUp => { + log::info!( + "drm: availability refresh failed {failures}x ({:?}); the producer looks \ + gone, dropping the cached verdict so the next enumeration re-probes", + result.as_ref().err() + ); + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Unknown); + } + } + }); + // Nothing to release: the guard moved into the closure and drops with it. Clearing the flag + // explicitly would let TWO PROBES RUN AT ONCE, since another refresh may already hold it. + if let Err(err) = spawned { + log::warn!( + "drm: could not spawn the availability refresh thread: {err}; the cached verdict \ + stays stale until the next probe" + ); + } +} + +pub(super) fn warm_availability() { + // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl + // cannot yet name the seat0 session. `is_x11_for_drm()` is that form minus the greeter + // blind spot, where plain `is_x11()` is permanently true. + for _ in 0..10 { + if crate::platform::linux::is_x11_for_drm() { + std::thread::sleep(Duration::from_millis(300)); + continue; + } + if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) { + return; + } + match query_displays() { + Ok(list) if !list.is_empty() => { + log::info!("drm: consumer cache warmed ({} displays) at startup", list.len()); + publish_probe_state(&mut DRM_STATE.lock().unwrap(), ProbeState::Available(Instant::now(), list)); + return; + } + _ => std::thread::sleep(Duration::from_millis(300)), + } + } + log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)"); +} + +/// The service holds its answer until the topology settles. Replaces only an `Available` verdict. +pub(super) async fn refresh_displays_for_login() { + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let t = Instant::now(); + match query_displays_inner().await { + Ok(list) if !list.is_empty() => { + let changed = { + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + log::debug!( + "drm: login display refresh superseded while probing; keeping the newer list" + ); + return; + } + match &*st { + ProbeState::Available(_, old) => { + let changed = *old != list; + log::debug!( + "drm: login display refresh -> {} display(s) in {:?}{}", + list.len(), + t.elapsed(), + if changed { " (list changed)" } else { "" } + ); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + changed + } + _ => return, + } + }; + if changed { + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + Ok(_) => log::debug!( + "drm: login display refresh found no displays in {:?}; keeping the cached list", + t.elapsed() + ), + Err(err) => log::debug!( + "drm: login display refresh failed in {:?} ({err}); keeping the cached list", + t.elapsed() + ), + } +} + +/// Mirrors get_display_infos: only a MULTI-display host advertises a demoted display. +pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { + // Snapshot the identity keys under DRM_STATE, then consult health with DRM_STATE RELEASED -- + // same order as get_display_infos: never hold DRM_STATE while taking a per-display map. + let (len, keys): (usize, Vec) = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => ( + list.len(), + if list.len() > 1 { + list.iter().map(connector_key).collect() + } else { + Vec::new() + }, + ), + _ => return None, + }; + let any_demoted = if len > 1 { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + keys.iter() + .any(|k| health.get(k).is_some_and(|h| h.demoted())) + } else { + false + }; + Some((len, any_demoted)) +} + +// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it +// offline; a single connector remains usable through the whole-desktop fallback. +fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { + if list.len() <= 1 { + return; + } + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (display, info) in list.iter().zip(infos.iter_mut()) { + if health + .get(&connector_key(display)) + .is_some_and(|health| health.demoted()) + { + info.online = false; + } + } +} + +fn primary_index_from_assignment(assignment: &[Option], primary: usize) -> usize { + assignment + .iter() + .position(|assigned| *assigned == Some(primary)) + .unwrap_or(0) +} + +/// Releases DRM_STATE before taking the Wayland and health locks. +pub(super) fn get_display_infos_and_primary() -> Option<(Vec, usize)> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(&list, &wl.displays); + let mut infos = augment_with_wayland_geometry_from(&list, &wl, &assignment); + mark_demoted_displays(&list, &mut infos); + // Primary and geometry must use the same connector assignment snapshot. + let primary = primary_index_from_assignment(&assignment, wl.primary); + Some((infos, primary)) +} + +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let mut infos = augment_with_wayland_geometry(&list); + mark_demoted_displays(&list, &mut infos); + Some(infos) +} + +/// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +/// +/// Asked at login screens too, on purpose: a greeter runs a compositor, and the socket fallback in +/// hbb_common lets the enumerator reach it with no environment variables. Where that fallback +/// cannot answer, the list comes back empty and everything stays unaugmented, which is what the +/// old is-login-screen gate produced unconditionally. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(drm, &wl.displays); + augment_with_wayland_geometry_from(drm, &wl, &assignment) +} + +fn augment_with_wayland_geometry_from( + drm: &[DrmDisplayInfo], + wl: &scrap::wayland::display::Displays, + matched: &[Option], +) -> Vec { + let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); + // A single display is still augmented: on a multi-GPU host the one connector this service can + // open may sit at a non-zero origin in the compositor layout, and DRM alone reports (0,0). + if drm.is_empty() { + return infos; + } + if wl.displays.is_empty() { + return infos; + } + // One connector against one output is the origin-only case: the lone output can still sit at + // a non-zero origin this side cannot see, but it keeps the scale-1 convention — a single + // display is advertised at physical size (see `logical_rects_of`), so its logical size must + // not be adopted. More connectors than the one output is an inconsistent snapshot, and the + // layout-order fallback in `assign_wayland_outputs` would plant that origin on a guess. + let origin_only = wl.displays.len() == 1; + if origin_only && drm.len() > 1 { + return infos; + } + for (i, info) in infos.iter_mut().enumerate() { + let Some(w) = matched[i].map(|j| &wl.displays[j]) else { + continue; + }; + info.x = w.x; + info.y = w.y; + if origin_only { + continue; + } + if let Some((lw, lh)) = w.logical_size { + if lw > 0 && lh > 0 { + info.scale = drm[i].width as f64 / lw as f64; + info.original_resolution = super::display_service::get_original_resolution( + &drm[i].name, + lw as usize, + lh as usize, + ); + } + } + } + infos +} + +/// Each output goes to at most one connector; unmatched ones take the next free output of the same +/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at +/// DRM's (0,0). +fn assign_wayland_outputs( + drm: &[DrmDisplayInfo], + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], +) -> Vec> { + let mut taken = vec![false; wl.len()]; + let mut matched: Vec> = vec![None; drm.len()]; + for (i, d) in drm.iter().enumerate() { + if let Some(j) = match_wayland_display(d, wl, &taken) { + matched[i] = Some(j); + taken[j] = true; + } + } + for (i, d) in drm.iter().enumerate() { + if matched[i].is_some() { + continue; + } + let free_same_size = wl + .iter() + .enumerate() + .position(|(j, w)| !taken[j] && w.width == d.width as i32 && w.height == d.height as i32); + let Some(j) = free_same_size.or_else(|| taken.iter().position(|t| !t)) else { + continue; // more connectors than outputs; leave the rest unaugmented + }; + log::warn!( + "drm: connector {} matched no compositor output by name or by a unique resolution; \ + falling back to layout order and taking {} at ({}, {})", + d.name, + wl[j].name, + wl[j].x, + wl[j].y + ); + matched[i] = Some(j); + taken[j] = true; + } + matched +} + +fn match_wayland_display( + d: &DrmDisplayInfo, + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], + taken: &[bool], +) -> Option { + let dn = normalize_connector(&d.name); + if let Some((j, _)) = wl + .iter() + .enumerate() + .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) + { + return Some(j); + } + let same_res: Vec = wl + .iter() + .enumerate() + .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) + .map(|(j, _)| j) + .collect(); + if same_res.len() == 1 { + return Some(same_res[0]); + } + None +} + +/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1"). +/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2". +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 3 && parts[1].len() == 1 && parts[1].chars().all(|c| c.is_ascii_alphabetic()) { + format!("{}-{}", parts[0], parts[2]) + } else { + name.to_string() + } +} + +fn swap_available_displays(list: Vec) { + let mut st = DRM_STATE.lock().unwrap(); + if matches!(&*st, ProbeState::Available(..)) { + if list.is_empty() { + log::info!("drm: hotplug refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!("drm: hotplug refresh -> {} display(s)", list.len()); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + } + } +} + +fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo { + let original_resolution = + super::display_service::get_original_resolution(&d.name, d.width as usize, d.height as usize); + DisplayInfo { + x: d.x, + y: d.y, + width: d.width as i32, + height: d.height as i32, + name: d.name.clone(), + online: d.active, + cursor_embedded: false, + original_resolution, + scale: 1.0, + ..Default::default() + } +} + +/// Deliberately does NOT publish the handshake list into DRM_STATE: it is read before a possibly +/// seconds-long stall, and when `wire_idx != display_idx` it is ordered differently. +pub(super) fn get_capturer_info( + display_idx: usize, +) -> ResultType { + let expected = display_info_of(display_idx as i32); + let key = expected.as_ref().map(connector_key); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + if let Some(h) = key.as_ref().and_then(|k| map.get_mut(k)) { + if h.zero_frame_streak >= DRM_GRAB_MAX_FAILURES { + if h.demoted() { + bail!( + "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" + ); + } + h.zero_frame_streak = 0; + h.since = Instant::now(); + } + } + } + // Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below. + let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?; + // The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window. + if let Some(key) = key.clone() { + let now = Instant::now(); + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.rapid_builds = match h.last_build { + Some(last) if now.duration_since(last) < RAPID_REBUILD_WINDOW => h.rapid_builds + 1, + _ => 0, + }; + h.last_build = Some(now); + if h.rapid_builds >= RAPID_REBUILD_MAX { + log::warn!( + "drm: display {display_idx} rebuilt {} times within {RAPID_REBUILD_WINDOW:?}; flapping, falling back to PipeWire", + h.rapid_builds + ); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.since = now; + h.demotes += 1; + bail!("drm capture for display {display_idx} is flapping; using PipeWire"); + } + } + let ndisplay = displays.len(); + // From the entry the stream was BOUND to; `display_idx` is a position in the CLIENT's list. + let d = displays + .get(wire_idx) + .ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))? + .clone(); + // Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin + // matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer. + let origin = augment_with_wayland_geometry(&displays) + .get(wire_idx) + .map(|di| (di.x, di.y)) + .unwrap_or((d.x, d.y)); + Ok(super::video_service::CapturerInfo { + origin, + width: d.width as usize, + height: d.height as usize, + ndisplay, + current: display_idx, + privacy_mode_id: 0, + _capturer_privacy_mode_id: 0, + capturer: Box::new(capturer), + }) +} + +#[cfg(test)] +mod drm_capturer_tests { + use super::*; + + fn capturer_with(session: Option<(usize, usize)>) -> IpcDrmCapturer { + capturer_named(session, None) + } + + // DRM_DISPLAY_HEALTH is process-wide and tests run in parallel: pass each test its OWN key. + fn capturer_named(session: Option<(usize, usize)>, key: Option<&str>) -> IpcDrmCapturer { + let connector = key.map(|k| k.to_owned()); + IpcDrmCapturer { + shared: Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }), + stop: Arc::new(AtomicBool::new(false)), + display: 0, + connector, + session_size: session, + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + } + } + + fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 { + let key = c.connector.clone().expect("this check needs an identity"); + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(&key) + .map(|h| h.zero_frame_streak) + .unwrap_or(0) + } + + fn put_frame(c: &IpcDrmCapturer, w: usize, h: usize) { + let mut buf = c.shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.resize(w * h * 4, 0); + let mut slot = c.shared.slot.lock().unwrap(); + slot.publish(w, h, Pixfmt::BGRA, buf); + } + + #[test] + fn a_delivered_frame_clears_the_streak_but_keeps_the_cadence_and_the_convert_verdict() { + let key = "test:frame-keeps-cadence"; + let mut c = capturer_named(Some((64, 32)), Some(key)); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key.to_owned()).or_insert_with(DisplayHealth::new); + h.zero_frame_streak = 2; + h.demotes = 1; + h.rapid_builds = 3; + h.last_build = Some(Instant::now()); + h.prefer_cpu = true; + } + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + + // Copy out and RELEASE the guard before asserting: a failing assertion while holding + // process-wide DRM_DISPLAY_HEALTH poisons the mutex for every sibling test. + let h = { + let map = DRM_DISPLAY_HEALTH.lock().unwrap(); + *map.get(key).expect("the entry must SURVIVE a delivered frame") + }; + assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); + assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); + assert_eq!( + h.rapid_builds, 3, + "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ + reach RAPID_REBUILD_MAX for a display that delivers a first frame and then fails" + ); + assert!(h.last_build.is_some(), "same for the timestamp the cadence is measured from"); + assert!( + h.prefer_cpu, + "and nothing about which GPU exports the scanout: only a topology change may clear it" + ); + } + + #[test] + fn frame_of_the_session_size_is_delivered() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!( + matches!(c.frame(Duration::from_millis(50)), Ok(_)), + "a frame matching the session geometry must be delivered" + ); + assert!(c.got_frame); + } + + #[test] + fn a_smaller_frame_ends_the_session_instead_of_being_encoded() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:mid-session-shrink")); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a mid-session shrink must be a hard error, not a delivered frame"), + }; + assert!(err.to_string().contains("changed geometry mid-session")); + assert!( + c.got_frame, + "the rebuild must not look like a display that never produced a frame" + ); + assert_eq!( + zero_frame_streak_of(&c), + 0, + "a session that streamed must not be counted as one that produced nothing" + ); + } + + #[test] + fn a_first_frame_that_never_matched_counts_as_a_session_without_frames() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:never-matched")); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a first frame off the advertised geometry must be a hard error"), + }; + assert!(err.to_string().contains("never matched its advertised geometry")); + assert!(!c.got_frame, "no frame reached the encoder, so none was produced"); + assert_eq!( + zero_frame_streak_of(&c), + 1, + "the display must be on its way to a PipeWire demotion, not just rebuilding" + ); + } + + #[test] + fn a_larger_frame_ends_the_session_too() { + let mut c = capturer_with(Some((1280, 720))); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Err(_))); + } + + #[test] + fn unknown_session_size_delivers_whatever_arrives() { + let mut c = capturer_with(None); + put_frame(&c, 800, 600); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + } + + fn drm_display(name: &str, w: u32, h: u32) -> DrmDisplayInfo { + DrmDisplayInfo { + name: name.to_owned(), + crtc_id: 1, + x: 0, + y: 0, + width: w, + height: h, + active: true, + render_node: String::new(), + device: String::new(), + } + } + + fn wl_display( + name: &str, + x: i32, + y: i32, + w: i32, + h: i32, + ) -> hbb_common::platform::linux::WaylandDisplayInfo { + hbb_common::platform::linux::WaylandDisplayInfo { + name: name.to_owned(), + x, + y, + width: w, + height: h, + logical_size: Some((w, h)), + refresh_rate: 60, + } + } + + #[test] + fn one_connector_assignment_drives_geometry_and_primary() { + let drm = [ + drm_display("HDMI-A-1", 1920, 1080), + drm_display("DP-1", 2560, 1440), + ]; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![ + wl_display("DP-1", 1920, 0, 2560, 1440), + wl_display("HDMI-1", 0, 0, 1920, 1080), + ], + }; + + let assignment = assign_wayland_outputs(&drm, &wl.displays); + let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment); + assert_eq!((infos[0].x, infos[1].x), (0, 1920)); + assert_eq!(primary_index_from_assignment(&assignment, wl.primary), 1); + } + + #[test] + fn frame_buffers_circulate_instead_of_being_reallocated() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + put_frame(&c, 64, 32); + let recycled = c + .shared + .slot + .lock() + .unwrap() + .free + .iter() + .find_map(|b| b.as_ref()) + .map(|b| b.as_ptr()); + assert!( + recycled.is_some(), + "a superseded frame must be handed back, not dropped" + ); + put_frame(&c, 64, 32); + assert_eq!( + c.shared + .slot + .lock() + .unwrap() + .latest + .as_ref() + .map(|(.., b)| b.as_ptr()), + recycled, + "the receive path must refill the recycled buffer rather than allocate" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert!( + c.shared.slot.lock().unwrap().free.iter().any(|b| b.is_some()), + "the buffer the encoder finished with must be handed back to the receive path" + ); + } + + // Against a single free slot this asserts red: counting the offers is the point. + #[test] + fn two_idle_buffers_are_both_kept_rather_than_one_being_dropped() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + while c.shared.slot.lock().unwrap().take_free().is_some() {} + + put_frame(&c, 64, 32); // fills a fresh buffer (nothing on offer) and publishes it + put_frame(&c, 64, 32); // supersedes it -> deposit #1 + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 1, + "the superseded frame is the first idle buffer" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 2, + "both idle buffers must be kept; a single slot dropped the older one" + ); + } + + #[test] + fn outputs_are_matched_by_name_across_the_drm_naming_difference() { + let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; + let wl = [wl_display("DP-1", 1920, 0, 2560, 1440), wl_display("HDMI-1", 0, 0, 1920, 1080)]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(1), Some(0)]); + } + + // The M10 case: same model and resolution, names that do not normalize to the compositor's. + #[test] + fn identical_monitors_that_match_no_name_take_layout_order() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn one_output_is_never_claimed_by_two_connectors() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 3840, 2160), + ]; + let got = assign_wayland_outputs(&drm, &wl); + assert_eq!(got[0], Some(0)); + assert_ne!(got[0], got[1], "two connectors must not share one output"); + } + + #[test] + fn a_name_match_beats_the_positional_fallback() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("HDMI-A-1", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("HDMI-1", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn extra_connectors_stay_unmatched() { + let drm = [ + drm_display("DP-1", 1920, 1080), + drm_display("DP-2", 1920, 1080), + drm_display("DP-3", 1920, 1080), + ]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1), None]); + } + + #[test] + fn refresh_keeps_a_verdict_through_one_failure_and_gives_it_up_after_a_run() { + assert_eq!(refresh_outcome(Some(3), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(1), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(0), 0), RefreshOutcome::Unavailable); + assert_eq!(refresh_outcome(None, 1), RefreshOutcome::Restamp); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES - 1), + RefreshOutcome::Restamp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES), + RefreshOutcome::GiveUp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES + 5), + RefreshOutcome::GiveUp + ); + } + + #[test] + fn a_dead_producer_stops_being_advertised() { + let mut outcome = RefreshOutcome::Restamp; + for failures in 1..=DRM_REFRESH_MAX_FAILURES { + outcome = refresh_outcome(None, failures); + } + assert_eq!(outcome, RefreshOutcome::GiveUp); + assert!( + DRM_REFRESH_MAX_FAILURES >= 2, + "a single transient failure must never be enough to drop the verdict" + ); + } + + #[test] + fn health_reports_demoted_only_while_the_cooldown_runs() { + let mut h = DisplayHealth::new(); + assert!(!h.demoted(), "a fresh display is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES - 1; + assert!(!h.demoted(), "one session short of the threshold is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.demotes = 1; + assert!(h.demoted(), "at the threshold, inside the cooldown"); + h.since = Instant::now() - demote_cooldown(h.demotes) - Duration::from_secs(1); + assert!(!h.demoted(), "past the cooldown the display must be retried"); + h.demotes = 4; + assert!(h.demoted(), "the backoff must still be holding it at demotion 4"); + } + + #[test] + fn demote_cooldown_doubles_per_cycle_and_caps() { + assert_eq!(demote_cooldown(1), DEMOTE_COOLDOWN); + assert_eq!(demote_cooldown(2), DEMOTE_COOLDOWN * 2); + assert_eq!(demote_cooldown(3), DEMOTE_COOLDOWN * 4); + let cap = DEMOTE_COOLDOWN * (1 << DEMOTE_BACKOFF_MAX_SHIFT); + assert_eq!(demote_cooldown(1 + DEMOTE_BACKOFF_MAX_SHIFT), cap); + assert_eq!(demote_cooldown(50), cap); + assert_eq!(demote_cooldown(u32::MAX), cap); + assert_eq!(demote_cooldown(0), DEMOTE_COOLDOWN); + } + + #[test] + fn a_permanently_ungrabbable_display_stops_churning() { + let burn = Duration::from_secs(5); // four failed sessions + assert!(demote_cooldown(1) + burn < Duration::from_secs(40)); + assert!(demote_cooldown(5) + burn > Duration::from_secs(8 * 60)); + } +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 91a2901dc14..f8f943276b0 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -396,19 +396,62 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> if let Some(hcursor) = crate::get_cursor()? { if hcursor != state.hcursor { let msg; + // On the DRM path get_cursor_data() may return a snapshot whose id has advanced past the + // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND + // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact + // shape dedupes correctly instead of being suppressed. Everything below is fully + // gated on the drm feature, so the drm-off build stays byte-identical to upstream. + #[cfg(all(target_os = "linux", feature = "drm"))] + let mut drm_served_id = hcursor; if let Some(cached) = state.cached_cursor_data.get(&hcursor) { super::log::trace!("Cursor data cached, hcursor: {}", hcursor); msg = cached.clone(); } else { let mut data = crate::get_cursor_data(hcursor)?; + // File the shape under the id ACTUALLY served, not the one requested. Deliberately a + // NEW name rather than shadowing `hcursor`: the insert below reads as the requested + // id everywhere else in this function, and a cfg-gated shadow would make the two + // builds disagree about what that line means. + #[cfg(all(target_os = "linux", feature = "drm"))] + let served_id = data.id; + #[cfg(all(target_os = "linux", feature = "drm"))] + { + drm_served_id = served_id; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + let cache_key = served_id; + #[cfg(not(all(target_os = "linux", feature = "drm")))] + let cache_key = hcursor; data.colors = hbb_common::compress::compress(&data.colors[..]).into(); let mut tmp = Message::new(); tmp.set_cursor_data(data); msg = Arc::new(tmp); - state.cached_cursor_data.insert(hcursor, msg.clone()); - super::log::trace!("Cursor data updated, hcursor: {}", hcursor); + // A DRM cursor id is derived from the shape's pixels plus geometry, so an animated + // pointer mints a new id on every shape change and this map would grow for the life + // of the service, each entry pinning a compressed cursor message. (Upstream's X11 + // ids come from a small set of XFixes serials, so the map is effectively bounded + // there -- which is why the ceiling is gated and the stock build stays untouched.) + // Past the ceiling, drop the map and start over: the next request for any evicted + // shape just recompresses it, and the ceiling comfortably covers every static shape + // plus a generous animation window. + #[cfg(all(target_os = "linux", feature = "drm"))] + { + const CURSOR_CACHE_MAX: usize = 64; + if state.cached_cursor_data.len() >= CURSOR_CACHE_MAX { + state.cached_cursor_data.clear(); + } + } + state.cached_cursor_data.insert(cache_key, msg.clone()); + super::log::trace!("Cursor data updated, hcursor: {}", cache_key); + } + #[cfg(not(all(target_os = "linux", feature = "drm")))] + { + state.hcursor = hcursor; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + { + state.hcursor = drm_served_id; } - state.hcursor = hcursor; sp.send_shared(msg.clone()); state.cursor_data = msg; } @@ -620,17 +663,22 @@ pub async fn setup_uinput(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultT let mouse = super::uinput::client::UInputMouse::new().await?; log::info!("UInput mouse created"); - ENIGO - .lock() - .unwrap() - .set_custom_keyboard(Box::new(keyboard)); - ENIGO.lock().unwrap().set_custom_mouse(Box::new(mouse)); + let mut en = ENIGO.lock().unwrap(); + // enigo guessed x11 once at construction, which is what a Wayland greeter reads as, and + // then routes the devices installed below to a null xdo that drops everything silently. + // Reaching here means `wayland_use_uinput()` was true, so this states a fact. + en.set_is_x11(false); + // One lock for both, so there is no window where the keyboard is custom and the mouse is not. + en.set_custom_keyboard(Box::new(keyboard)); + en.set_custom_mouse(Box::new(mouse)); Ok(()) } #[cfg(target_os = "linux")] pub async fn setup_rdp_input() -> ResultType<(), Box> { let mut en = ENIGO.lock()?; + // Same as `setup_uinput`: the caller is gated on `wayland_use_rdp_input()`. + en.set_is_x11(false); let rdp_info_lock = RDP_SESSION_INFO.lock()?; let rdp_info = rdp_info_lock.as_ref().ok_or("RDP session is None")?; @@ -661,20 +709,22 @@ pub async fn setup_rdp_input() -> ResultType<(), Box> { pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> { set_uinput_resolution(minx, maxx, miny, maxy).await?; - std::thread::spawn(|| { + // Confirm the device adopted the new range before the caller caches it. + // spawn_blocking because ENIGO is a std Mutex and send_refresh blocks on IPC. + tokio::task::spawn_blocking(move || { if let Some(mouse) = ENIGO.lock().unwrap().get_custom_mouse() { if let Some(mouse) = mouse .as_mut_any() .downcast_mut::() { - allow_err!(mouse.send_refresh()); - } else { - log::error!("failed downcast uinput mouse"); + return mouse.send_refresh(); } + bail!("failed to downcast custom mouse to UInputMouse"); } - }); - - Ok(()) + // No custom mouse: nothing to refresh. + Ok(()) + }) + .await? } #[cfg(target_os = "linux")] @@ -1098,12 +1148,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { MOUSE_TYPE_MOVE => { // Switching back to absolute movement implicitly disables relative mouse mode. set_relative_mouse_active(conn, false); - en.mouse_move_to(evt.x, evt.y); + // On Wayland with uinput, the client sends coordinates in the layout it was + // told at session init. If the compositor has since moved a monitor, correct + // them onto the current layout. https://github.com/rustdesk/rustdesk/issues/15601 + #[cfg(target_os = "linux")] + let (mx, my) = if wayland_use_uinput() { + super::display_service::remap_wayland_uinput_coord(evt.x, evt.y) + } else { + (evt.x, evt.y) + }; + #[cfg(not(target_os = "linux"))] + let (mx, my) = (evt.x, evt.y); + en.mouse_move_to(mx, my); *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { conn, time: get_time(), - x: evt.x, - y: evt.y, + x: mx, + y: my, }; } // MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications. diff --git a/src/server/rdp_input.rs b/src/server/rdp_input.rs index 81189159d3c..546f946c0d3 100644 --- a/src/server/rdp_input.rs +++ b/src/server/rdp_input.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::sync::Arc; pub mod client { - use hbb_common::platform::linux::is_kde; + use hbb_common::platform::linux::{DISPLAY_DESKTOP_KDE, XDG_CURRENT_DESKTOP}; use super::*; @@ -302,6 +302,19 @@ pub mod client { } } + fn desktop_is_niri(desktop: &str) -> bool { + desktop + .split(':') + .any(|name| name.eq_ignore_ascii_case("niri")) + } + + lazy_static::lazy_static! { + static ref SHOULD_SCALE_POINTER_COORDINATES: bool = + std::env::var(XDG_CURRENT_DESKTOP) + .map(|desktop| desktop == DISPLAY_DESKTOP_KDE || desktop_is_niri(&desktop)) + .unwrap_or(false); + } + pub struct RdpInputMouse { conn: Arc, session: Path<'static>, @@ -327,7 +340,7 @@ pub mod client { // For Ubuntu 24.04(Gnome 46), (x,y) is restricted from (0,0) to (400,300), but the actual range in screen is: // Logic coordinate from (0,0) to (200x150). // Or physical coordinate from (0,0) to (400,300). - let scale = if is_kde() { + let scale = if *SHOULD_SCALE_POINTER_COORDINATES { if resolution.0 == 0 || stream.get_size().0 == 0 { Some(1.0f64) } else { @@ -348,6 +361,19 @@ pub mod client { } } + #[cfg(test)] + mod tests { + use super::desktop_is_niri; + + #[test] + fn detects_niri_in_desktop_list() { + assert!(desktop_is_niri("niri")); + assert!(desktop_is_niri("NIRI")); + assert!(desktop_is_niri("GNOME:niri")); + assert!(!desktop_is_niri("GNOME")); + } + } + impl MouseControllable for RdpInputMouse { fn as_any(&self) -> &dyn std::any::Any { self diff --git a/src/server/terminal_service.rs b/src/server/terminal_service.rs index 52a296b7422..8664a99276c 100644 --- a/src/server/terminal_service.rs +++ b/src/server/terminal_service.rs @@ -1857,15 +1857,33 @@ impl TerminalServiceProxy { // Process each session with its own lock for (terminal_id, session_arc) in sessions { if let Ok(mut session) = session_arc.try_lock() { - // Check if reader thread is still alive and we haven't sent closed message yet + // Check if the session has ended (reader thread finished or child exited). + // On Linux, the PTY reader thread may not return EOF when the shell exits + // (the cloned master fd keeps the read side open), so we also poll the child + // process via try_wait() as a fallback detection mechanism. let mut should_send_closed = false; if !session.closed_message_sent { if let Some(thread) = &session.reader_thread { if thread.is_finished() { should_send_closed = true; - session.closed_message_sent = true; } } + if !should_send_closed { + if let Some(child) = &mut session.child { + match child.try_wait() { + Ok(Some(_)) => { + should_send_closed = true; + } + Ok(None) => {} // still running + Err(e) => { + log::warn!("Terminal {} child wait error: {}", terminal_id, e); + } + } + } + } + if should_send_closed { + session.closed_message_sent = true; + } } // It's Ok to put the closed message here. // Because the `reader_thread` is joined in `stop()`, @@ -2018,7 +2036,8 @@ impl TerminalServiceProxy { } } } else { - // For persistent sessions, just clear the child reference + // For persistent sessions, clear the child reference and remove the session + // if the closed message has been sent (shell has exited). if let Some(session_arc) = sessions.get(&terminal_id) { let mut session = session_arc.lock().unwrap(); if let Some(mut child) = session.child.take() { @@ -2028,6 +2047,12 @@ impl TerminalServiceProxy { } add_to_reaper(child); } + if session.closed_message_sent { + // Shell has exited, remove the dead session + drop(session); + sessions.remove(&terminal_id); + service.lock().unwrap().sessions.remove(&terminal_id); + } } } diff --git a/src/server/uinput.rs b/src/server/uinput.rs index a1947d79fb7..496da709f1c 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -130,7 +130,16 @@ pub mod client { } pub fn send_refresh(&mut self) -> ResultType<()> { - self.send(Data::Mouse(DataMouse::Refresh)) + self.rt + .block_on(self.conn.send(&Data::Mouse(DataMouse::Refresh)))?; + // Wait for the service to confirm it recreated the device, so a + // failed refresh is distinguishable from a good one. + match self.rt.block_on(self.conn.next_timeout(IPC_REQUEST_TIMEOUT)) { + Ok(Some(Data::Empty)) => Ok(()), + Ok(Some(resp)) => bail!("unexpected uinput mouse refresh response: {:?}", &resp), + Ok(None) => bail!("uinput mouse refresh failed, connection closed"), + Err(e) => bail!("uinput mouse refresh timeout {}, {}", IPC_REQUEST_TIMEOUT, e), + } } } @@ -851,9 +860,10 @@ pub mod service { match data { Data::Mouse(data) => { if let DataMouse::Refresh = data { - let resolution = RESOLUTION.lock().unwrap(); - let rng_x = resolution.0.clone(); - let rng_y = resolution.1.clone(); + let (rng_x, rng_y) = { + let resolution = RESOLUTION.lock().unwrap(); + (resolution.0.clone(), resolution.1.clone()) + }; log::info!( "Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})", rng_x.0, @@ -861,11 +871,19 @@ pub mod service { rng_y.0, rng_y.1 ); - mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) { - Ok(mouse) => mouse, + match mouce::UInputMouseManager::new(rng_x, rng_y) { + Ok(m) => { + mouse = m; + // Ack: device adopted the new range. + allow_err!(stream.send(&Data::Empty).await); + } Err(e) => { - log::error!("Failed to create mouse, {}", e); - return; + // Keep the current device; withhold the ack + // so the client times out and retries. + log::error!( + "Failed to recreate uinput mouse, keeping current: {}", + e + ); } } } else { diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 15f0ef89364..9d97b1ce984 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -77,7 +77,7 @@ lazy_static::lazy_static! { pub static ref VIDEO_QOS: Arc> = Default::default(); pub static ref IS_UAC_RUNNING: Arc> = Default::default(); pub static ref IS_FOREGROUND_WINDOW_ELEVATED: Arc> = Default::default(); - static ref SCREENSHOTS: Mutex> = Default::default(); + static ref SCREENSHOTS: Mutex> = Default::default(); } struct Screenshot { @@ -192,7 +192,7 @@ impl VideoFrameController { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum VideoSource { Monitor, Camera, @@ -725,7 +725,8 @@ fn run(vs: VideoService) -> ResultType<()> { Ok(frame) => { repeat_encode_counter = 0; if frame.valid() { - let screenshot = SCREENSHOTS.lock().unwrap().remove(&display_idx); + let screenshot_key = (vs.source, display_idx); + let screenshot = SCREENSHOTS.lock().unwrap().remove(&screenshot_key); if let Some(mut screenshot) = screenshot { let restore_vram = screenshot.restore_vram; let (msg, w, h, data) = match &frame { @@ -754,7 +755,10 @@ fn run(vs: VideoService) -> ResultType<()> { #[cfg(all(windows, feature = "vram"))] VRamEncoder::set_not_use(sp.name(), true); screenshot.restore_vram = true; - SCREENSHOTS.lock().unwrap().insert(display_idx, screenshot); + SCREENSHOTS + .lock() + .unwrap() + .insert(screenshot_key, screenshot); _raii.try_vram = false; bail!("SWITCH"); } @@ -1348,9 +1352,9 @@ fn check_qos( Ok(()) } -pub fn set_take_screenshot(display_idx: usize, sid: String, tx: Sender) { +pub fn set_take_screenshot(source: VideoSource, display_idx: usize, sid: String, tx: Sender) { SCREENSHOTS.lock().unwrap().insert( - display_idx, + (source, display_idx), Screenshot { sid, tx, diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 1e0efc0f480..023e9e55947 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,124 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be +/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so +/// unlike `desktop_rect_of` there is no logical size to handle. +#[cfg(feature = "drm")] +fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { + let displays = super::drm_capturer::get_display_infos()?; + if displays.is_empty() { + return None; + } + let minx = displays.iter().map(|d| d.x).min()?; + let miny = displays.iter().map(|d| d.y).min()?; + let maxx = displays.iter().map(|d| d.x + d.width).max()?; + let maxy = displays.iter().map(|d| d.y + d.height).max()?; + if maxx <= minx || maxy <= miny { + return None; + } + Some((minx, maxx, miny, maxy)) +} + +/// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps +/// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The +/// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it +/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the +/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured +/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is +/// independent of the capture backend. +/// +/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the +/// same three things, for the same reasons: +/// +/// - drops the cached Wayland layout first, because it can predate compositor changes made while no +/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition; +/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of +/// its own, so a hung uinput socket would otherwise block every video-service start on this branch +/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so +/// that every later hotplug refresh is silently skipped for the process lifetime; +/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the +/// #15601 drift remap. Without it the remap never activates on the DRM path at all. +/// +/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in +/// every Linux build and this feature must not change the drm-off one by so much as a line. +#[cfg(feature = "drm")] +pub(super) async fn update_uinput_resolution() { + if !crate::input_service::wayland_use_uinput() { + return; + } + // Compositor first at a login screen too: a greeter runs one, and the hbb_common socket + // fallback reaches it with no environment variables. The DRM union is the fallback, and it is + // a real loss to land there on a multi-monitor host: DRM has no origins, so its union rect + // mis-maps the pointer whenever the compositor arranged the outputs side by side. + // + // Off the executor: the compositor query can block for the socket probe deadline, and this + // runs on current-thread runtimes (session init and the hotplug worker). The layout baseline + // is computed in the SAME task: a failed lookup is not cached, so asking for the rects + // afterwards would rerun the whole socket probe synchronously. + let (rect, layout) = match hbb_common::tokio::task::spawn_blocking(|| { + scrap::wayland::display::clear_wayland_displays_cache(); + match scrap::wayland::display::get_desktop_rect_for_uinput() { + // The lookup above just cached the displays, so the rects come from that snapshot. + Some(rect) => Some((rect, scrap::wayland::display::get_display_rects_for_uinput())), + // Raw DRM union: there is no compositor layout to baseline. Empty keeps the #15601 + // remap inactive, which is right when the origins are unknown anyway. + None => drm_desktop_rect_for_uinput().map(|rect| (rect, Vec::new())), + } + }) + .await + { + Ok(Some(pair)) => pair, + Ok(None) => { + log::warn!("Failed to get desktop rect for uinput"); + return; + } + Err(err) => { + log::warn!("The desktop rect probe task failed: {err}"); + return; + } + }; + // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and + // the baseline is what the client's coordinates are measured against. + let snapshot_layout = || { + super::display_service::set_wayland_layout_baseline(layout.clone()); + }; + // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop + // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a + // uinput device reconfiguration under a user who may be at the console. + if super::display_service::wayland_uinput_rect() == Some(rect) { + snapshot_layout(); + return; + } + let (minx, maxx, miny, maxy) = rect; + log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})"); + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + // Record the rect only after a successful apply, so a transient failure is retried on the + // next call instead of being remembered as applied. + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(rect); + snapshot_layout(); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } +} + #[tokio::main(flavor = "current_thread")] pub(super) async fn ensure_inited() -> ResultType<()> { + // DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over + // IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput + // desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init). + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + update_uinput_resolution().await; + return Ok(()); + } check_init().await } @@ -116,6 +232,10 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + return None; + } if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { @@ -137,6 +257,9 @@ pub(super) async fn check_init() -> ResultType<()> { if !is_x11() { if CAP_DISPLAY_INFO.read().unwrap().is_empty() { if crate::input_service::wayland_use_uinput() { + // The cached layout may predate compositor changes made while no session + // was active, https://github.com/rustdesk/rustdesk/issues/15601 + scrap::wayland::display::clear_wayland_displays_cache(); if let Some((minx, maxx, miny, maxy)) = scrap::wayland::display::get_desktop_rect_for_uinput() { @@ -147,9 +270,28 @@ pub(super) async fn check_init() -> ResultType<()> { miny, maxy ); - allow_err!( - input_service::update_mouse_resolution(minx, maxx, miny, maxy).await - ); + // Bound the IPC wait like the periodic refresh does, so a hung + // response can't stall session init. + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(( + minx, maxx, miny, maxy, + )); + // Snapshot the per-display layout the client's coordinates + // will be based on, so the mouse path can correct them if + // the compositor moves a monitor mid-session. + super::display_service::set_wayland_layout_baseline( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } } else { log::warn!("Failed to get desktop rect for uinput"); } @@ -175,8 +317,7 @@ pub(super) async fn check_init() -> ResultType<()> { *PIPEWIRE_INITIALIZED.write().unwrap() = true; let num = all.len(); let primary = super::display_service::get_primary_2(&all); - super::display_service::check_update_displays(&all); - let mut displays = super::display_service::get_sync_displays(); + let mut displays = super::display_service::update_sync_displays(&all); for display in displays.iter_mut() { display.cursor_embedded = is_cursor_embedded(); } @@ -220,27 +361,36 @@ pub(super) async fn check_init() -> ResultType<()> { Ok(()) } -pub(super) async fn get_displays() -> ResultType> { - check_init().await?; - let cap_map = CAP_DISPLAY_INFO.read().unwrap(); - if let Some(addr) = cap_map.values().next() { - let cap_display_info: *const CapDisplayInfo = *addr as _; - unsafe { - let cap_display_info = &*cap_display_info; - Ok(cap_display_info.displays.clone()) +pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + // This function runs once per login (update_get_sync_displays_on_login is its only + // caller), and login is the moment the client is PROMISED a display list -- so refresh + // that list over a live `_drm` handshake first. The service wakes sleeping displays and + // answers with the settled truth, which is what makes an unattended box with an idled, + // DISABLED panel connectable at all: the cached list would either omit the panel (probed + // while asleep) or advertise a display with no scanout behind it (probed while awake), and + // either way the wake then firing inside the capture handshake would change the list the + // client had already been given. Properly async, so the executor is never blocked; on any + // failure the cache serves as before. + super::drm_capturer::refresh_displays_for_login().await; + let snapshot = hbb_common::tokio::task::spawn_blocking( + super::drm_capturer::get_display_infos_and_primary, + ) + .await + .map_err(|err| anyhow::anyhow!("Wayland display probe task failed: {err}"))?; + if let Some(snapshot) = snapshot { + return Ok(snapshot); } - } else { - bail!("Failed to get capturer display info"); } -} - -pub(super) fn get_primary() -> ResultType { + check_init().await?; + // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); if let Some(addr) = cap_map.values().next() { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; - Ok(cap_display_info.primary) + Ok((cap_display_info.displays.clone(), cap_display_info.primary)) } } else { bail!("Failed to get capturer display info"); @@ -251,6 +401,19 @@ pub fn clear() { if is_x11() { return; } + // The DRM path augments its geometry from the compositor's Wayland outputs (logical origin + + // scale), which scrap caches process-wide. The PipeWire path clears that cache on session close, + // but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs + // against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown + // so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + scrap::wayland::display::clear_wayland_displays_cache(); + } + // NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer + // teardown (which happens on each video-service restart), and re-probing `_drm` from the async + // enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral + // into a restart loop. DRM availability is fixed at service start, so the cache stays valid. let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); for (_, addr) in write_lock.iter() { let cap_display_info: *mut CapDisplayInfo = *addr as _; @@ -265,18 +428,136 @@ pub fn clear() { *PIPEWIRE_INITIALIZED.write().unwrap() = false; } +/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display +/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits +/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper +/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed +/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so +/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video +/// service instead of degrading to PipeWire only for itself. +#[cfg(feature = "drm")] +#[tokio::main(flavor = "current_thread")] +async fn ensure_pipewire_inited() -> ResultType<()> { + check_init().await +} + pub(super) fn get_capturer_for_display( display_idx: usize, ) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } + // DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing + // the PipeWire CAP_DISPLAY_INFO machinery entirely. `is_available()` is a GLOBAL verdict, so a + // per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a + // render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out + // and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this + // display; the other DRM outputs keep streaming over DRM. + // The ONE gate that keeps the probing form on purpose: this runs on the plain video thread, + // not an async executor, and it is the capture-build path, so a definitive verdict is worth + // seconds here. It is also what makes a cold cache recoverable at all -- warm_availability + // gives up after its attempts, so if EVERY gate were cache-only a --server that started + // before the root service would never see DRM again for the rest of its life. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + match super::drm_capturer::get_capturer_info(display_idx) { + Ok(info) => return Ok(info), + Err(e) => { + log::warn!( + "drm capturer for display {} unavailable ({:#}); falling back to PipeWire", + display_idx, + e + ); + ensure_pipewire_inited()?; + } + } + } + // Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs + // `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes + // the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM + // display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard + // across that roundtrip would stall every concurrent teardown for its duration, and the value + // does not depend on anything inside the guard. + #[cfg(feature = "drm")] + let drm_advertised = if super::drm_capturer::is_available_cached() { + match super::drm_capturer::get_display_infos() { + Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)), + None => Some((None, false)), + } + } else { + None + }; let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + // Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's + // `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing + // one entry to two `display_idx` values would let two video-service threads call `frame()` on the + // same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong + // rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop + // stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is + // handled at the source by dropping the demoted display from the advertised list (see + // drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being + // papered over with a shared/mismatched capturer. if let Some(addr) = cap_map.get(&display_idx) { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; let rect = cap_display_info.rects[cap_display_info.current]; + // Reaching here with DRM active means get_capturer_info bailed (a demoted display) and + // we fell through to PipeWire. Serve this stream ONLY if its rect matches the + // geometry we advertised for this index. The portal typically exposes one whole-desktop + // stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM + // geometry is a single connector -> serving it would stretch the frame and offset all + // input. Bail instead; get_display_infos advertised the display offline, so the client + // re-enumerates against a consistent list. A single-display host matches (whole-desktop == + // that display) and is served normally. On a pure-PipeWire host is_available() is false and + // this guard is skipped, preserving upstream behavior exactly. + #[cfg(feature = "drm")] + if let Some((advertised, single_display)) = drm_advertised { + if let Some(advertised) = advertised { + // BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed, + // because it was twice "corrected" to a scale conversion that broke it: + // `rect` is built above from `Display::width()/height()`, and the WAYLAND + // variant of those returns `physical_width()/physical_height()` + // (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`. + // `try_fix_logical_size` only repairs the capturable's SEPARATE + // `logical_size` field and never touches `physical_size`, so the rect is not + // logical. The advertised DRM geometry is physical too + // (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves + // width/height as the DRM mode). Dividing one side by the scale therefore + // compares logical against physical and rejects the valid stream on exactly + // the scaled outputs it was meant to rescue. + // + // The size check is what tells one connector apart from the whole-desktop + // rect the portal usually exposes. It is skipped only when BOTH sides say + // there is a single display -- the DRM list has one entry and the PipeWire + // map has one -- because only then is "the whole-desktop stream IS this + // display" true by construction. (The portal can report a different physical + // size for a Full Workspace selection than the connector's mode, which is why + // that case needs the carve-out at all.) The DRM count alone is not enough: + // a monitor on a card the service cannot open is missing from the DRM list + // while the compositor still drives it. + let single_display = single_display && cap_display_info.num == 1; + let consistent = advertised.x == rect.0 .0 + && advertised.y == rect.0 .1 + && (single_display + || (advertised.width as usize == rect.1 + && advertised.height as usize == rect.2)); + if !consistent { + bail!( + "drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", + display_idx, + advertised.width, + advertised.height, + advertised.x, + advertised.y, + rect.1, + rect.2, + rect.0 .0, + rect.0 .1 + ); + } + } + } Ok(super::video_service::CapturerInfo { origin: rect.0, width: rect.1, diff --git a/src/service.rs b/src/service.rs index ce1855bdb8b..65be58302d7 100644 --- a/src/service.rs +++ b/src/service.rs @@ -5,6 +5,14 @@ fn main() {} #[cfg(target_os = "macos")] fn main() { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && args[1] == "--write-plists" { + if let Err(e) = librustdesk::platform::write_plists() { + eprintln!("Failed to write plists: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } crate::common::load_custom_client(); hbb_common::init_log(false, "service"); crate::start_os_service(); diff --git a/src/tray.rs b/src/tray.rs index bd2952cdff2..0b7e38542dc 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -30,6 +30,15 @@ fn make_tray() -> hbb_common::ResultType<()> { menu::{Menu, MenuEvent, MenuItem}, TrayIcon, TrayIconBuilder, TrayIconEvent as TrayEvent, }; + + // Duplicated tray icons kept piling up through the blind spots of + // `check_process("--tray", ..)`. https://github.com/rustdesk/rustdesk/issues/15689 + #[cfg(windows)] + if !crate::platform::windows::try_lock_tray_single_instance() { + log::info!("Another tray process is already running in this session, exit"); + return Ok(()); + } + let icon; #[cfg(target_os = "macos")] { @@ -185,9 +194,26 @@ fn make_tray() -> hbb_common::ResultType<()> { return; } */ + // Remove the icon first: on success `uninstall_service()` ends + // this process with `std::process::exit`, which skips the + // destructor that would remove it, leaving a ghost icon behind. + #[cfg(windows)] + let _ = _tray_icon + .lock() + .unwrap() + .as_mut() + .map(|t| t.set_visible(false)); if !crate::platform::uninstall_service(false, false) { *control_flow = ControlFlow::Exit; } + // Still alive, so stopping the service failed or was cancelled + // in the UAC prompt. Show the icon again. + #[cfg(windows)] + let _ = _tray_icon + .lock() + .unwrap() + .as_mut() + .map(|t| t.set_visible(true)); } else if event.id == open_i.id() { open_func(); } diff --git a/src/ui.rs b/src/ui.rs index 6d0d0927aeb..c95a8ec04d7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,7 @@ use hbb_common::{ log, }; -#[cfg(not(any(feature = "flutter", feature = "cli")))] +#[cfg(not(feature = "flutter"))] use crate::ui_session_interface::Session; use crate::{common::get_app_name, ipc, ui_interface::*}; @@ -29,7 +29,7 @@ lazy_static::lazy_static! { static ref STUPID_VALUES: Mutex>>> = Default::default(); } -#[cfg(not(any(feature = "flutter", feature = "cli")))] +#[cfg(not(feature = "flutter"))] lazy_static::lazy_static! { pub static ref CUR_SESSION: Arc>>> = Default::default(); } @@ -151,7 +151,7 @@ pub fn start(args: &mut [String]) { frame.register_behavior("native-remote", move || { let handler = remote::SciterSession::new(cmd.clone(), id.clone(), pass.clone(), args.clone()); - #[cfg(not(any(feature = "flutter", feature = "cli")))] + #[cfg(not(feature = "flutter"))] { *CUR_SESSION.lock().unwrap() = Some(handler.inner()); } diff --git a/src/ui/common.tis b/src/ui/common.tis index 240799059fb..a1a0b8fac3a 100644 --- a/src/ui/common.tis +++ b/src/ui/common.tis @@ -296,6 +296,15 @@ function msgbox(type, title, content, link="", callback=null, height=180, width= else msgbox("connecting", "Connecting...", "Logging in..."); } }; + } else if (type.indexOf("insecure-connection") >= 0) { + callback = function (res) { + if (!res) { + handler.continue_insecure_connection(false); + view.close(); + return; + } + handler.continue_insecure_connection(true); + }; } else if (type.indexOf("custom") < 0 && !is_port_forward && !callback) { callback = function() { view.close(); } } else if (type == 'wait-remote-accept-nook') { @@ -479,4 +488,4 @@ class MultipleSessionComponent extends Reactor.Component { ; } -} \ No newline at end of file +} diff --git a/src/ui/header.tis b/src/ui/header.tis index 40ccbcbf2ee..231c71efe8e 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -151,7 +151,7 @@ class Header: Reactor.Component { {svg_action} {svg_display} {svg_keyboard} - {recording_enabled ? {recording ? svg_recording_on : svg_recording_off} : ""} + {recording_enabled && show_recording_button ? {recording ? svg_recording_on : svg_recording_off} : ""} {this.renderKeyboardPop()} {this.renderDisplayPop()} {this.renderActionPop()} diff --git a/src/ui/index.tis b/src/ui/index.tis index a099b95f957..5ca96f45bba 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -603,7 +603,7 @@ class MyIdMenu: Reactor.Component {
Fingerprint: " + handler.get_fingerprint() + " \
" + translate("Privacy Statement") + "
\
" + translate("Website") + "
\ -
Copyright © 2025 Purslane Ltd.\ +
Copyright © 2026 Purslane Tech Pte. Ltd.\
" + handler.get_license() + " \

" + translate("Slogan_tip") + "

\
\ diff --git a/src/ui/msgbox.tis b/src/ui/msgbox.tis index 6e6b6a62feb..58547ce582b 100644 --- a/src/ui/msgbox.tis +++ b/src/ui/msgbox.tis @@ -152,6 +152,7 @@ class MsgboxComponent: Reactor.Component { var hasOk = this.type != "connecting" && this.type != "success" && this.type.indexOf("nook") < 0; var hasLink = this.link != ""; var hasClose = this.type.indexOf("hasclose") >= 0; + var isInsecureConnection = this.type.indexOf("insecure-connection") >= 0; var show_progress = this.type == "connecting"; var me = this; self.timer(0, msgboxTimerFunc); @@ -176,11 +177,12 @@ class MsgboxComponent: Reactor.Component {
+ {isInsecureConnection && hasOk ? : ""} {hasCancel || this.hasRetry ? : ""} {this.hasSkip() ? : ""} - {hasOk || this.hasRetry ? : ""} + {!isInsecureConnection && (hasOk || this.hasRetry) ? : ""} {hasLink ? : ""} - {hasClose ? : ""} + {hasClose ? (isInsecureConnection ? : ) : ""} {this.getScreenshotButtons()}
@@ -193,6 +195,10 @@ class MsgboxComponent: Reactor.Component { } function submit() { + if (this.type.indexOf("insecure-connection") >= 0) { + this.cancel(); + return; + } var submit_btn = this.$(button#submit); if (submit_btn) { if (submit_btn.state.disabled) return; @@ -376,7 +382,11 @@ class MsgboxComponent: Reactor.Component { var el = me.$(.outline-focus); if (el) view.focus = el; else { - el = me.$(#submit); + if (me.type.indexOf("insecure-connection") >= 0) { + el = me.$(#cancel); + } else { + el = me.$(#submit); + } if (el) { view.focus = el; } diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 8b6f01ae0d3..3a2cca3e027 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession { fn get_id(); fn get_default_pi(); fn get_option(String); + fn get_local_option(String); fn t(String); fn set_option(String, String); fn input_os_password(String, bool); @@ -513,6 +514,7 @@ impl sciter::EventHandler for SciterSession { fn is_rdp(); fn login(String, String, String, bool); fn send2fa(String, bool); + fn continue_insecure_connection(bool); fn get_enable_trusted_devices(); fn new_rdp(); fn send_mouse(i32, i32, i32, bool, bool, bool, bool); @@ -637,6 +639,10 @@ impl SciterSession { crate::client::translate(name) } + pub fn get_local_option(&self, key: String) -> String { + crate::ui_interface::get_local_option(key) + } + pub fn get_icon(&self) -> String { super::get_icon() } diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 28fbc3763fd..87c543eb006 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -17,6 +17,7 @@ var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var show_recording_button = handler.get_local_option("hide-recording-button") != "Y"; var privacy_mode_enabled = true; // server side var scroll_body = $(body); var peer_platform = ""; diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index cab0d7f1c1d..b62f59c543e 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -1686,6 +1686,19 @@ pub fn quit_cm() { // in case of std::process::exit not work log::info!("quit cm"); CLIENTS.write().unwrap().clear(); + // `quit_gui()` ends the process on Windows and macOS, but on Linux it calls + // `gtk_main_quit()`, which has no effect in the Flutter connection manager: + // `flutter/linux/main.cc` runs `g_application_run()` (GtkApplication), so + // `gtk_main()` is never called. Exit directly instead, otherwise this + // process keeps running while no longer serving the `_cm` ipc endpoint, so + // the server can't reuse it and spawns one more connection manager. + // + // NOTE: a client merely disconnecting does not come here, the Flutter side + // closes the window then, so this is a fallback rather than an explanation + // for the stale processes of #15698. + #[cfg(all(target_os = "linux", feature = "flutter"))] + std::process::exit(0); + #[cfg(not(all(target_os = "linux", feature = "flutter")))] crate::platform::quit_gui(); } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index e01595ef769..94fde439263 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -711,7 +711,6 @@ pub fn is_can_input_monitoring(_prompt: bool) -> bool { #[inline] pub fn get_error() -> String { - #[cfg(not(any(feature = "cli")))] #[cfg(target_os = "linux")] { let dtype = crate::platform::linux::get_display_server(); @@ -912,6 +911,29 @@ pub fn get_langs() -> String { json!(x).to_string() } +// Preserve relative paths for existing configurations and only remove accidental +// surrounding whitespace. Config values are not shell-expanded (for example, `~`). +fn trim_video_save_directory(value: &str) -> Option<&str> { + let value = value.trim(); + if !value.is_empty() { + Some(value) + } else { + None + } +} + +// A Windows service typically runs with System32 as its working directory, so +// require an absolute path to avoid resolving recordings there unexpectedly. +#[cfg(any(windows, test))] +fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> { + let value = trim_video_save_directory(value)?; + if std::path::Path::new(value).is_absolute() { + Some(value) + } else { + None + } +} + #[inline] pub fn video_save_directory(root: bool) -> String { let appname = crate::get_app_name(); @@ -931,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String { // Currently, only installed windows run as root #[cfg(windows)] { + let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY); + if let Some(dir) = validate_windows_service_video_save_directory(&dir) { + return dir.to_owned(); + } + if !dir.trim().is_empty() { + log::warn!( + "Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute" + ); + } let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned()); let dir = std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); @@ -942,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String { let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY); - if !dir.is_empty() { - return dir; + if let Some(dir) = trim_video_save_directory(&dir) { + return dir.to_owned(); } #[cfg(any(target_os = "android", target_os = "ios"))] if let Ok(home) = config::APP_HOME_DIR.read() { @@ -1706,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option { .lock() .unwrap() } + +#[cfg(test)] +mod tests { + use super::{trim_video_save_directory, validate_windows_service_video_save_directory}; + + #[test] + fn trim_configured_video_save_directory() { + assert_eq!( + trim_video_save_directory(" relative/recordings "), + Some("relative/recordings") + ); + assert_eq!(trim_video_save_directory(" "), None); + } + + #[test] + fn validate_service_video_save_directory() { + let absolute = if cfg!(windows) { + r"C:\recordings" + } else { + "/recordings" + }; + let padded = format!(" {absolute} "); + + assert_eq!( + validate_windows_service_video_save_directory(&padded), + Some(absolute) + ); + assert_eq!( + validate_windows_service_video_save_directory("recordings"), + None + ); + assert_eq!( + validate_windows_service_video_save_directory(&format!("\"{absolute}\"")), + None + ); + assert_eq!(validate_windows_service_video_save_directory(" "), None); + } +} diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 1e35672ec8e..03b59a49777 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -569,16 +569,6 @@ impl Session { self.send(Data::Message(msg)); } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn send_plugin_request(&self, request: PluginRequest) { - let mut misc = Misc::new(); - misc.set_plugin_request(request); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - self.send(Data::Message(msg_out)); - } - pub fn get_audit_server(&self, typ: String) -> String { if LocalConfig::get_option("access_token").is_empty() { return "".to_owned(); @@ -1408,6 +1398,15 @@ impl Session { self.send(Data::Close); } + pub fn continue_insecure_connection(&self, continue_insecure: bool) { + let data = if continue_insecure { + Data::ContinueInsecureConnection + } else { + Data::RejectInsecureConnection + }; + self.send(data); + } + fn try_auto_start_job_str(is_reconnected: bool, job_str: &str) -> Option { if is_reconnected { let job_str = job_str.trim(); @@ -1809,10 +1808,12 @@ impl Interface for Session { self.msgbox("error", "Error", msg, ""); return; } - self.try_change_init_resolution(pi.current_display); - let p = self.lc.read().unwrap().should_auto_login(); - if !p.is_empty() { - input_os_password(p, true, self.clone()); + if !self.is_view_camera() { + self.try_change_init_resolution(pi.current_display); + let p = self.lc.read().unwrap().should_auto_login(); + if !p.is_empty() { + input_os_password(p, true, self.clone()); + } } let current = &pi.displays[pi.current_display as usize]; self.set_display( @@ -1867,8 +1868,8 @@ impl Interface for Session { } } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) { - handle_hash(self.lc.clone(), pass, hash, self, peer).await; + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { + handle_hash(self.lc.clone(), pass, hash, self, peer).await } async fn handle_login_from_ui( diff --git a/src/updater.rs b/src/updater.rs index 56fdc1d2a8c..beab97e5375 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,8 +1,8 @@ -use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url}; +use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url_strict}; use hbb_common::{bail, config, log, ResultType}; use std::{ io::Write, - path::PathBuf, + path::{Component, Path, PathBuf}, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, @@ -11,6 +11,51 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "macos")] +use std::os::{ + fd::AsRawFd, + unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, +}; + +#[cfg(target_os = "macos")] +struct MacUpdateLock { + _file: std::fs::File, +} + +#[cfg(target_os = "macos")] +fn acquire_mac_update_lock() -> ResultType { + let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock"); + let handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC) + .open(&path)?; + let metadata = handle.metadata()?; + if !metadata.file_type().is_file() || metadata.uid() != 0 { + bail!("[root-update] update lock is not a root-owned regular file"); + } + handle.set_permissions(std::fs::Permissions::from_mode(0o600))?; + + // Keep the descriptor open through update preparation and detached-script + // launch. O_CLOEXEC means this lock does not cover the detached bundle + // swap; flock is released when this guard is dropped or the process exits. + let lock_result = unsafe { + hbb_common::libc::flock( + handle.as_raw_fd(), + hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB, + ) + }; + if lock_result != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + bail!("[root-update] another update is already running"); + } + return Err(err.into()); + } + Ok(MacUpdateLock { _file: handle }) +} + enum UpdateMsg { CheckUpdate, Exit, @@ -22,7 +67,17 @@ lazy_static::lazy_static! { static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); -const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); +/// Initial wait after startup before the first update check (30 seconds). +pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30); + +/// One full day — default interval between update checks. +pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); + +/// Minimum interval between consecutive update checks (10 minutes). +pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); + +/// Retry interval when an update check fails or a session is active (30 minutes). +pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); pub fn update_controlling_session_count(count: usize) { CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); @@ -47,7 +102,9 @@ pub fn stop_auto_update() { } #[inline] -fn has_no_active_conns() -> bool { +/// Returns true when there are no active incoming or outgoing connections. +/// Used to avoid updating while a remote session is in progress. +pub fn has_no_active_conns() -> bool { let conns = crate::Connection::alive_conns(); conns.is_empty() && has_no_controlling_conns() } @@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender { } fn start_auto_update_check_(rx_msg: Receiver) { - std::thread::sleep(Duration::from_secs(30)); + std::thread::sleep(INITIAL_CHECK_DELAY); if let Err(e) = check_update(false) { log::error!("Error checking for updates: {}", e); } - const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); - const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); let mut last_check_time = Instant::now(); let mut check_interval = DUR_ONE_DAY; loop { @@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver) { } fn check_update(manually: bool) -> ResultType<()> { + // On macOS, auto-update is handled by check_update_as_root() in the service process. + // The shared check_update() path is only used for manual update checks from the GUI. + #[cfg(target_os = "macos")] + if !manually { + return Ok(()); + } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { @@ -153,7 +214,7 @@ fn check_update(manually: bool) -> ResultType<()> { format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) }; log::debug!("New version available: {}", &version); - let client = create_http_client_with_url(&download_url); + let client = create_http_client_with_url_strict(&download_url)?; let Some(file_path) = get_download_file_from_url(&download_url) else { bail!("Failed to get the file path from the URL: {}", download_url); }; @@ -291,7 +352,341 @@ fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { } } -pub fn get_download_file_from_url(url: &str) -> Option { - let filename = url.split('/').last()?; +pub fn get_update_download_file_from_url(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + // Check the raw prefix before Url normalizes default ports. + if !url.starts_with("https://github.com/") + || parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + + let mut segments = parsed.path_segments()?; + let owner = segments.next()?; + let repo = segments.next()?; + let releases = segments.next()?; + let download = segments.next()?; + let tag = segments.next()?; + let filename = segments.next()?; + + if owner != "rustdesk" + || repo != "rustdesk" + || releases != "releases" + || download != "download" + || tag.is_empty() + || segments.next().is_some() + || !is_plain_update_filename(filename) + { + return None; + } + Some(std::env::temp_dir().join(filename)) } + +fn is_plain_update_filename(filename: &str) -> bool { + if filename.is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename.contains(':') + { + return false; + } + + let mut components = Path::new(filename).components(); + matches!( + components.next(), + Some(Component::Normal(name)) if name.to_str() == Some(filename) + ) && components.next().is_none() +} + +pub fn get_download_file_from_url(url: &str) -> Option { + get_update_download_file_from_url(url) +} + +/// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) +/// from every logged-in user's --server process via IPC. +/// The root service cannot read connection state directly since connections +/// live in user --server processes. Handles fast user switching by querying +/// all GUI users, including the login-window server at UID 0. Falls back to +/// false (assumes sessions active) on any IPC error to avoid updating during +/// an unknown session state. +#[cfg(target_os = "macos")] +pub fn has_no_active_conns_ipc() -> bool { + let rt = match hbb_common::tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(_) => return false, + }; + rt.block_on(async { + // Use the same GUI-domain-filtered UID set as the update script. + // Shell-only SSH/TTY users are excluded, while an empty GUI set maps + // to UID 0 so the LoginWindow server is queried rather than assumed idle. + let uids = crate::platform::get_logged_in_uids(); + // Check each user's server — fail closed if any has active connections + for uid in uids { + if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { + if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { + // Explicit no active connections — safe to continue + } + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { + return false; // Explicit active connections + } + _ => { + return false; // Timeout/error/unexpected — fail closed + } + } + } else { + return false; // Send failed — fail closed + } + } else { + return false; // Connection failed — fail closed + } + } + true // All users explicitly confirmed no active connections + }) +} + +#[cfg(target_os = "macos")] +fn wait_for_failed_update_retry() { + const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed"; + let marker = std::path::Path::new(FAILURE_MARKER); + if !marker.exists() { + return; + } + + // The updater script records failure immediately before launchd restarts + // the old daemon. Preserve the retry deadline across that restart instead + // of consuming the marker and retrying the same broken release in 30 sec. + let remaining = std::fs::metadata(marker) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| { + std::time::SystemTime::now() + .duration_since(modified) + .ok() + }) + .map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed)) + .unwrap_or(RETRY_INTERVAL); + if !remaining.is_zero() { + log::info!( + "[root-update] Previous update failed; retrying in {} seconds.", + remaining.as_secs() + ); + std::thread::sleep(remaining); + } + match std::fs::remove_file(marker) { + Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err), + } +} + +/// Starts the background silent auto-update scheduler for macOS. +/// Called from `start_os_service()` which runs as root via LaunchDaemon. +#[cfg(target_os = "macos")] +pub fn start_auto_update_macos() { + let spawn_result = std::thread::Builder::new() + .name("rustdesk-auto-update".to_owned()) + .spawn(|| { + log::info!("[root-update] Auto-update scheduler thread started."); + std::thread::sleep(INITIAL_CHECK_DELAY); + wait_for_failed_update_retry(); + let mut interval = DUR_ONE_DAY; + loop { + log::info!("[root-update] Running scheduled update check..."); + let no_active_conns = has_no_active_conns_ipc(); + if !no_active_conns { + log::info!("[root-update] Active session in progress, retrying in 10 min."); + interval = MIN_INTERVAL; + } else { + match check_update_as_root() { + Ok(update_started) => { + if update_started { + // The replacement script is detached and may fail + // after this process returns. Always retry at the + // failure interval until the new daemon replaces us. + interval = RETRY_INTERVAL; + } else { + interval = DUR_ONE_DAY; + } + } + Err(e) => { + log::error!("[root-update] Update check failed: {}", e); + interval = RETRY_INTERVAL; + } + } + } + std::thread::sleep(interval); + } + }); + if let Err(err) = spawn_result { + log::error!("[root-update] Failed to start scheduler thread: {}", err); + } +} + +#[cfg(target_os = "macos")] +pub fn check_update_as_root() -> ResultType { + let _update_lock = acquire_mac_update_lock()?; + // Allow-auto-update setting + if !config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE) { + log::info!("[root-update] Auto update is disabled, skipping."); + return Ok(false); + } + if crate::is_custom_client() { + log::info!("[root-update] Custom client detected, skipping stock update."); + return Ok(false); + } + // Clean up only old temp dirs from previous failed updates. The detached + // installer keeps using its update directory after this process exits and + // releases the advisory lock, so a newly-started daemon must not remove a + // directory that still belongs to the active transaction. + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(".rustdeskupdate-root-") + || name_str.starts_with(".rustdeskdownload-") + { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + let mode = metadata.mode() & 0o7777; + let is_stale = metadata + .modified() + .ok() + .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= RETRY_INTERVAL); + if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale + { + if let Err(err) = std::fs::remove_dir_all(&path) { + log::warn!( + "[root-update] Failed to remove stale temp dir {}: {}", + path.display(), + err + ); + } + } + } + } + } + if let Err(e) = do_check_software_update() { + bail!("[root-update] Failed to check for software update: {}", e); + } + let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); + if update_url.is_empty() { + log::info!("[root-update] No update available."); + return Ok(false); + } + let download_url = update_url.replace("tag", "download"); + let version = download_url.split('/').last().unwrap_or_default().to_string(); + let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; + let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); + log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); + // Validate URL against GitHub release allowlist before downloading as root + let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { + bail!("[root-update] URL failed allowlist check: {}", dmg_url); + }; + drop(file_path_validated); + let client = create_http_client_with_url_strict(&dmg_url)?; + // Use mktemp so a local user cannot pre-create a predictable path and + // permanently deny updates for a reused service PID. + let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskdownload-XXXXXX"]) + .output()?; + if !private_tmp_output.status.success() { + bail!( + "[root-update] Failed to create private download directory: {}", + String::from_utf8_lossy(&private_tmp_output.stderr).trim() + ); + } + let private_tmp = String::from_utf8(private_tmp_output.stdout) + .map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))? + .trim() + .to_owned(); + if private_tmp.is_empty() { + bail!("[root-update] mktemp returned an empty download directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; + } + let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); + let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); + let tmp_path = file_path.to_string_lossy().to_string(); + // Download + let mut response = client.get(&dmg_url).send()?; + if !response.status().is_success() { + let _ = std::fs::remove_dir_all(&private_tmp); + bail!("[root-update] Failed to download: {}", response.status()); + } + // Create file exclusively (O_EXCL) and stream response directly into it + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&file_path) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + std::io::copy(&mut response, &mut file) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + } + log::info!("[root-update] Downloaded to {}", tmp_path); + // Recheck active sessions before installing — download can take minutes + if !has_no_active_conns_ipc() { + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + bail!("[root-update] Active session started during download, deferring update."); + } + // Install silently as root + let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); + // Clean up download directory + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + result.map(|_| true) +} + +#[cfg(test)] +mod tests { + use super::get_download_file_from_url; + + #[test] + fn update_download_file_accepts_expected_github_asset_urls() { + let file = get_download_file_from_url( + "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", + ) + .expect("valid GitHub release asset URL"); + + assert_eq!( + file.file_name().and_then(|name| name.to_str()), + Some("rustdesk-1.4.0-x86_64.dmg") + ); + } + + #[test] + fn update_download_file_rejects_untrusted_or_malformed_urls() { + for url in [ + "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://example.com/rustdesk.exe", + "https://github.com/other/project/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/", + "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", + "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", + "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", + "not a url", + ] { + assert!(get_download_file_from_url(url).is_none(), "{url}"); + } + } +} diff --git a/tests/test_invariant_wf_cliprdr.c b/tests/test_invariant_wf_cliprdr.c deleted file mode 100644 index 3a75a5f3ee8..00000000000 --- a/tests/test_invariant_wf_cliprdr.c +++ /dev/null @@ -1,92 +0,0 @@ -#include -#include -#include - -#include "../libs/clipboard/src/windows/wf_cliprdr.c" - -static SIZE_T descriptor_size(UINT count) -{ - return offsetof(FILEGROUPDESCRIPTORW, fgd) + (SIZE_T)count * sizeof(FILEDESCRIPTORW); -} - -START_TEST(test_descriptor_size_rejects_buffer_smaller_than_header) -{ - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid(0, 1), FALSE); - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid( - offsetof(FILEGROUPDESCRIPTORW, fgd) - 1, 1), - FALSE); -} -END_TEST - -START_TEST(test_descriptor_size_rejects_zero_items) -{ - ck_assert_int_eq( - wf_cliprdr_file_group_descriptor_size_valid(offsetof(FILEGROUPDESCRIPTORW, fgd), 0), - FALSE); -} -END_TEST - -START_TEST(test_descriptor_size_accepts_max_stream_count) -{ - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid( - descriptor_size(WF_CLIPRDR_MAX_STREAMS), WF_CLIPRDR_MAX_STREAMS), - TRUE); -} -END_TEST - -START_TEST(test_descriptor_size_rejects_stream_count_above_limit) -{ - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid( - descriptor_size(WF_CLIPRDR_MAX_STREAMS), WF_CLIPRDR_MAX_STREAMS + 1), - FALSE); -} -END_TEST - -START_TEST(test_descriptor_size_rejects_truncated_descriptor_array) -{ - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid(descriptor_size(2) - 1, 2), - FALSE); -} -END_TEST - -START_TEST(test_descriptor_size_rejects_extreme_count) -{ - ck_assert_int_eq(wf_cliprdr_file_group_descriptor_size_valid((SIZE_T)-1, (UINT)-1), - FALSE); -} -END_TEST - -Suite *wf_cliprdr_invariant_suite(void) -{ - Suite *s; - TCase *tc_core; - - s = suite_create("wf_cliprdr_invariants"); - tc_core = tcase_create("descriptor_size"); - - tcase_add_test(tc_core, test_descriptor_size_rejects_buffer_smaller_than_header); - tcase_add_test(tc_core, test_descriptor_size_rejects_zero_items); - tcase_add_test(tc_core, test_descriptor_size_accepts_max_stream_count); - tcase_add_test(tc_core, test_descriptor_size_rejects_stream_count_above_limit); - tcase_add_test(tc_core, test_descriptor_size_rejects_truncated_descriptor_array); - tcase_add_test(tc_core, test_descriptor_size_rejects_extreme_count); - - suite_add_tcase(s, tc_core); - return s; -} - -int main(void) -{ - int number_failed; - Suite *s; - SRunner *sr; - - s = wf_cliprdr_invariant_suite(); - sr = srunner_create(s); - - srunner_run_all(sr, CK_NORMAL); - number_failed = srunner_ntests_failed(sr); - srunner_free(sr); - - return (number_failed == 0) ? EXIT_SUCCESS : EXIT_FAILURE; -} diff --git a/tests/test_workflow_input_contract.sh b/tests/test_workflow_input_contract.sh index 762a59be449..73972117d9b 100755 --- a/tests/test_workflow_input_contract.sh +++ b/tests/test_workflow_input_contract.sh @@ -22,7 +22,7 @@ root = Path(sys.argv[1]) manifest_writer = (root / ".github" / "scripts" / "write_artifact_manifest.py").read_text() workflow_names = [ "bridge.yml", - "rustqs-windows-min-test.yml", + "rustqs-windows.yml", "rustqs-linux.yml", "rustqs-android.yml", ] @@ -66,7 +66,7 @@ def bash_contract(workflow): blocks.append(block) if shell and "bash" not in str(shell): continue - if not shell and workflow.name == "rustqs-windows-min-test.yml": + if not shell and workflow.name == "rustqs-windows.yml": continue normalized = re.sub(r"\$\{\{.*?\}\}", "placeholder", block) check = subprocess.run( @@ -114,8 +114,8 @@ for name in workflow_names: raise AssertionError(f"{name}: custom_.txt content must not be persisted in GITHUB_ENV") if "RQS_CUSTOM_TXT_FILE" not in text: raise AssertionError(f"{name}: restrictive custom_.txt file handoff is missing") - if "cp --" not in text or "output/custom_.txt" not in text: - raise AssertionError(f"{name}: private custom_.txt is not copied beside public output for manifest declaration") + if "output/custom_.txt" in text: + raise AssertionError(f"{name}: private custom_.txt must not be published beside public output") created_at = text.index("custom_txt_file=") trap_at = text.index("trap cleanup_custom_txt_on_failure EXIT", created_at) written_at = text.index('printf \'%s\' "$RQS_CT" > "$custom_txt_file"', trap_at) @@ -220,6 +220,10 @@ for name in workflow_names[1:]: if "--verify-bridge" not in restore_contract or "--expected-version" not in restore_contract or 'cp -- "$BRIDGE_ARTIFACT_DIR/$file" "$file"' not in restore_contract: raise AssertionError(f"{name}: bridge manifest verification must precede source restoration") +windows_jobs = yaml.safe_load((root / ".github" / "workflows" / "rustqs-windows.yml").read_text())["jobs"] +if windows_jobs["topmost"].get("needs") != "bridge": + raise AssertionError("rustqs-windows.yml: topmost must depend on bridge") + bridge_text = (root / ".github" / "workflows" / "bridge.yml").read_text() stage_start = bridge_text.index("- name: Stage generated bridge files") stage_end = bridge_text.index("- name:", stage_start + 1) @@ -423,7 +427,7 @@ deb_assertion = linux_text.index('dpkg-deb -c "$deb_source"') deb_copy = linux_text.index('cp -- "$deb_source" "$deb_output"') if deb_assertion > deb_copy: raise AssertionError("Linux workflow must assert custom_.txt membership before publishing the Debian artifact") -for marker in ("rpmbuild -ba res/rpm-flutter.spec", "output/custom_.txt", "Cleanup sensitive custom_.txt"): +for marker in ("rpmbuild -ba res/rpm-flutter.spec", "Cleanup sensitive custom_.txt"): if marker not in linux_text: raise AssertionError(f"Linux RPM/private-manifest contract is missing {marker!r}") @@ -449,7 +453,7 @@ def run_manifest_writer(output, app_name="rustqs", platform="windows"): "--workflow-sha", "b" * 40, "--workflow-ref", - "rustqs/min-test", + "rustqs/workflows", ], cwd=root, env=environment, @@ -482,7 +486,7 @@ def run_manifest_writer_with_mocked_provenance(output, platform="windows", app_n "--workflow-sha", "b" * 40, "--workflow-ref", - "rustqs/min-test", + "rustqs/workflows", ] os.environ["RQS_SOURCE_SHA"] = "a" * 40 os.environ["MANIFEST_PUBLICATION_TIMESTAMP"] = "2026-08-10T12:00:00Z" @@ -503,7 +507,7 @@ def run_manifest_writer_with_mocked_provenance(output, platform="windows", app_n return 0 -def verify_bridge_artifact(output, source_sha="a" * 40, workflow_sha="b" * 40, workflow_ref="rustqs/min-test", version="1.2.3"): +def verify_bridge_artifact(output, source_sha="a" * 40, workflow_sha="b" * 40, workflow_ref="rustqs/workflows", version="1.2.3"): module_path = root / ".github" / "scripts" / "write_artifact_manifest.py" spec = importlib.util.spec_from_file_location("deskforge_bridge_verifier", module_path) module = importlib.util.module_from_spec(spec) @@ -531,7 +535,7 @@ def verify_bridge_artifact_with_cli(output): "--workflow-sha", "b" * 40, "--workflow-ref", - "rustqs/min-test", + "rustqs/workflows", ], cwd=root, text=True, @@ -554,11 +558,8 @@ with tempfile.TemporaryDirectory() as output_dir: (output / "rustqs.exe").write_bytes(b"safe") (output / "custom_.txt").write_bytes(b"private settings") result = run_manifest_writer_with_mocked_provenance(output) - if result != 0: - raise AssertionError(f"manifest writer rejected declared private custom_.txt: {result}") - manifest = json.loads((output / "manifest.txt").read_text()) - if manifest["private_filenames"] != ["custom_.txt"] or any(file["name"] == "custom_.txt" for file in manifest["files"]): - raise AssertionError(f"private custom_.txt was not separated from public files: {manifest}") + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError("manifest writer accepted a private custom_.txt sidecar") with tempfile.TemporaryDirectory() as output_dir: output = Path(output_dir) @@ -620,11 +621,8 @@ for platform, app_name, names in ( (output / name).write_bytes(name.encode()) (output / "custom_.txt").write_bytes(b"private settings") result = run_manifest_writer_with_mocked_provenance(output, platform, app_name) - if result != 0: - raise AssertionError(f"{platform}: manifest writer rejected public/private compatibility set: {result}") - manifest = json.loads((output / "manifest.txt").read_text()) - if manifest["output_filenames"] != names or manifest["private_filenames"] != ["custom_.txt"]: - raise AssertionError(f"{platform}: manifest file separation is invalid: {manifest}") + if result == 0 or (output / "manifest.txt").exists(): + raise AssertionError(f"{platform}: manifest writer accepted a private custom_.txt sidecar") with tempfile.TemporaryDirectory() as output_dir: output = Path(output_dir) diff --git a/vcpkg.json b/vcpkg.json index 634c32b1911..cd282fc1c29 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -95,6 +95,9 @@ }, "overlay-ports": [ "./res/vcpkg" + ], + "overlay-triplets": [ + "./res/vcpkg-triplets" ] }, "overrides": [