From c051e89b65e622e8f40ff5a11d9a584dda499d9d Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:33:54 +1100 Subject: [PATCH 001/121] feat: add rustqs linux build workflow (from DeskForge) --- .github/workflows/rustqs-linux.yml | 238 +++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 .github/workflows/rustqs-linux.yml diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml new file mode 100644 index 00000000000..b16d03a3504 --- /dev/null +++ b/.github/workflows/rustqs-linux.yml @@ -0,0 +1,238 @@ +name: rustqs linux min test +# ============================================================================ +# DRAFT (B-012) — НЕ ВАЛИДИРОВАН РЕАЛЬНЫМ ПРОГОНОМ GITHUB ACTIONS. +# Перенос generator-linux.yml в контракт форка (enc_payload + L1/L2/L3), по образцу +# github-build/rustqs-windows-min-test.yml. Build-шаги Flutter-Linux требуют доводки +# на реальных прогонах (vcpkg, движок, build.py, упаковка, пути артефактов) — как это +# делалось для windows-min-test. Бэкенд уже умеет диспетчить platform=linux сюда и +# забирать артефакт `rustdesk-min-test-linux`. +# +# Контракт параметров идентичен rustqs-windows-min-test: +# enc_payload = base64(openssl aes-256-cbc -pbkdf2 -pass pass:$WORKFLOW_PAYLOAD_KEY) +# от JSON {server,key,app_name,custom_txt}; либо открытые inputs (debug). +# +# Путь в форке: .github/workflows/rustqs-linux.yml на ветке rustqs/min-test. +# ============================================================================ + +on: + workflow_dispatch: + inputs: + enc_payload: + description: 'Encrypted payload (base64 openssl aes-256-cbc -pbkdf2). Overrides open inputs below.' + required: false + type: string + default: '' + server: + description: '[Debug] RustDesk server (rendezvous host:port). Ignored if enc_payload set.' + required: false + type: string + default: '' + key: + description: '[Debug] RustDesk server public key (base64). Ignored if enc_payload set.' + required: false + type: string + default: '' + app_name: + description: '[Debug] Brand name. Ignored if enc_payload set.' + required: false + type: string + default: '' + custom_txt: + description: '[Debug] Base64 custom_.txt payload. Ignored if enc_payload set.' + required: false + type: string + default: '' + +env: + RUST_VERSION: "1.75" + LLVM_VERSION: "15.0.6" + FLUTTER_VERSION: "3.24.5" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VERSION: "1.4.8" + +jobs: + bridge: + uses: ./.github/workflows/bridge.yml + + build: + needs: [bridge] + runs-on: ubuntu-22.04 + steps: + - name: Maximize build space + run: | + sudo rm -rf /opt/ghc /usr/local/lib/android /usr/share/dotnet + df -h + + - name: Export GitHub Actions cache environment variables + uses: actions/github-script@v6 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Checkout source code + uses: actions/checkout@v4 + with: + submodules: recursive + + # Разрешение параметров: расшифровать enc_payload (prod) или взять открытые inputs (debug). + # Идентично windows-min-test: на выходе env RQS_*, секреты замаскированы. + - name: 'Resolve build config (decrypt or pass-through)' + shell: bash + env: + ENC: ${{ inputs.enc_payload }} + PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} + IN_SERVER: ${{ inputs.server }} + IN_KEY: ${{ inputs.key }} + IN_APP: ${{ inputs.app_name }} + IN_CT: ${{ inputs.custom_txt }} + run: | + set -eu + if [ -n "${ENC:-}" ]; then + if [ -z "${PAYLOAD_KEY:-}" ]; then + echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" + exit 1 + fi + decrypted=$(printf '%s' "$ENC" | base64 -d \ + | openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${PAYLOAD_KEY}") + RQS_SERVER=$(printf '%s' "$decrypted" | jq -r '.server // ""') + RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') + RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') + RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + else + RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}" + fi + for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done + { + echo "RQS_SERVER=$RQS_SERVER" + echo "RQS_KEY=$RQS_KEY" + echo "RQS_APP_NAME=$RQS_APP" + echo "RQS_CUSTOM_TXT=$RQS_CT" + } >> "$GITHUB_ENV" + + # L1: вшить сервер+ключ в config.rs (платформо-независимо, идентично windows). + - name: 'L1 inject: server + key into hbb_common/config.rs' + if: env.RQS_SERVER != '' || env.RQS_KEY != '' + shell: bash + run: | + set -eu + f=libs/hbb_common/src/config.rs + if [ -n "${RQS_SERVER:-}" ]; then + esc=$(printf '%s' "$RQS_SERVER" | sed -e 's/[\/&]/\\&/g') + sed -i "s/rs-ny\.rustdesk\.com/${esc}/" "$f" + grep -F -q -- "$RQS_SERVER" "$f" || { echo "L1: server marker not found"; exit 1; } + fi + if [ -n "${RQS_KEY:-}" ]; then + esc=$(printf '%s' "$RQS_KEY" | sed -e 's/[\/&]/\\&/g') + sed -i "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|${esc}|" "$f" + grep -F -q -- "$RQS_KEY" "$f" || { echo "L1: key marker not found"; exit 1; } + fi + + # L2: allowCustom — снять проверку подписи custom.txt (платформо-независимо). + - name: 'L2 patch: allowCustom' + if: env.RQS_CUSTOM_TXT != '' + shell: bash + run: | + set -eu + python3 .github/patches/rdgen-allowCustom.py + if grep -q '5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=' src/common.rs; then + echo "L2: allowCustom FAILED"; exit 1 + fi + + # L3: брендинг для Linux (без .exe/Runner.rc — только Cargo.toml/portable/lang). + - name: 'L3 brand: rename RustDesk → app_name' + if: env.RQS_APP_NAME != '' + shell: bash + run: | + set -eu + esc=$(printf '%s' "$RQS_APP_NAME" | sed -e 's/[\/&]/\\&/g') + for f in Cargo.toml libs/portable/Cargo.toml; do + sed -i -e "s|description = \"RustDesk Remote Desktop\"|description = \"${esc}\"|" "$f" + done + find ./src/lang -name "*.rs" -exec sed -i -e "s|RustDesk|${esc}|" {} \; + + - name: Restore bridge files + uses: actions/download-artifact@v8 + with: + name: bridge-artifact + path: ./ + + - name: Build dependencies (apt) + run: | + sudo apt-get update -y + sudo apt-get install -y nasm libva-dev imagemagick \ + libgtk-3-dev libxcb-randr0-dev libxdo-dev libxfixes-dev \ + libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev \ + libpam0g-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + targets: x86_64-unknown-linux-gnu + components: "rustfmt" + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ubuntu-22.04 + + - name: Disable rust bridge build (cdylib only) + run: sed -i 's/\["cdylib", "staticlib", "rlib"\]/\["cdylib"\]/g' Cargo.toml + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + doNotCache: false + + - name: Install vcpkg dependencies + run: | + if ! $VCPKG_ROOT/vcpkg install --triplet x64-linux --x-install-root="$VCPKG_ROOT/installed"; then + find "${VCPKG_ROOT}/" -name "*.log" | while read -r f; do echo "=== $f ==="; cat "$f"; done + exit 1 + fi + + - name: Install flutter + uses: subosito/flutter-action@v2.12.0 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Build rustdesk (Flutter Linux) + run: | + export VCPKG_ROOT=/opt/artifacts/vcpkg + python3 ./build.py --flutter --hwcodec + + # L2 payload: положить custom_.txt рядом с бинарём в bundle (читается клиентом). + - name: 'L2 payload: place custom_.txt into bundle' + if: env.RQS_CUSTOM_TXT != '' + shell: bash + run: | + set -eu + dst=flutter/build/linux/x64/release/bundle/custom_.txt + printf '%s' "$RQS_CUSTOM_TXT" > "$dst" + ls -la "$dst" + + - name: Package artifact + shell: bash + run: | + set -eu + APP="${RQS_APP_NAME:-rustdesk}" + mkdir -p ./output + bundle=flutter/build/linux/x64/release/bundle + # Переименуем основной бинарь в ${APP} и упакуем bundle в tar.gz. + if [ -f "$bundle/rustdesk" ]; then + cp "$bundle/rustdesk" "$bundle/${APP}" || true + fi + tar -C "$bundle" -czf "./output/${APP}-linux-x86_64.tar.gz" . + # Плюс «голый» бинарь для прямого запуска/быстрой проверки. + [ -f "$bundle/${APP}" ] && cp "$bundle/${APP}" "./output/${APP}" || true + ls -lh ./output/ + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: rustdesk-min-test-linux + path: output From 11cf556cc40ec6d65f98dc649806a50f3ddd586e Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:33:55 +1100 Subject: [PATCH 002/121] feat: add rustqs android build workflow (from DeskForge) --- .github/workflows/rustqs-android.yml | 237 +++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .github/workflows/rustqs-android.yml diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml new file mode 100644 index 00000000000..3d510dacae8 --- /dev/null +++ b/.github/workflows/rustqs-android.yml @@ -0,0 +1,237 @@ +name: rustqs android min test +# ============================================================================ +# DRAFT (B-012) — НЕ ВАЛИДИРОВАН РЕАЛЬНЫМ ПРОГОНОМ GITHUB ACTIONS. +# Перенос generator-android.yml в контракт форка (enc_payload + L1/L2/L3), по +# образцу github-build/rustqs-windows-min-test.yml / rustqs-linux.yml. Сборка одной +# ABI (arm64-v8a) для min-test. Build-шаги (NDK/cargo-ndk/flutter build apk/пути) +# требуют доводки на реальных прогонах. Бэкенд диспетчит platform=android сюда и +# забирает артефакт `rustdesk-min-test-android`. +# +# Контракт параметров идентичен windows/linux: +# enc_payload = base64(openssl aes-256-cbc -pbkdf2 -pass pass:$WORKFLOW_PAYLOAD_KEY) +# от JSON {server,key,app_name,custom_txt}; либо открытые inputs (debug). +# +# ОГРАНИЧЕНИЕ ЧЕРНОВИКА: способ вшивания custom_.txt в Android-клиент отличается от +# desktop (assets/flutter), здесь сделано best-effort и требует проверки. +# +# Путь в форке: .github/workflows/rustqs-android.yml на ветке rustqs/min-test. +# ============================================================================ + +on: + workflow_dispatch: + inputs: + enc_payload: + description: 'Encrypted payload (base64 openssl aes-256-cbc -pbkdf2). Overrides open inputs below.' + required: false + type: string + default: '' + server: + description: '[Debug] RustDesk server (rendezvous host:port). Ignored if enc_payload set.' + required: false + type: string + default: '' + key: + description: '[Debug] RustDesk server public key (base64). Ignored if enc_payload set.' + required: false + type: string + default: '' + app_name: + description: '[Debug] Brand name. Ignored if enc_payload set.' + required: false + type: string + default: '' + custom_txt: + description: '[Debug] Base64 custom_.txt payload. Ignored if enc_payload set.' + required: false + type: string + default: '' + +env: + RUST_VERSION: "1.75" + FLUTTER_VERSION: "3.24.5" + CARGO_NDK_VERSION: "3.1.2" + NDK_VERSION: "r28c" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VERSION: "1.4.8" + +jobs: + bridge: + uses: ./.github/workflows/bridge.yml + + build: + needs: [bridge] + runs-on: ubuntu-22.04 + steps: + - name: Free disk space + run: | + sudo rm -rf /opt/ghc /usr/share/dotnet /usr/local/lib/android/sdk/ndk + df -h + + - name: Export GitHub Actions cache environment variables + uses: actions/github-script@v6 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Checkout source code + uses: actions/checkout@v4 + with: + submodules: recursive + + # Разрешение параметров (идентично windows/linux): enc_payload (prod) или open inputs. + - name: 'Resolve build config (decrypt or pass-through)' + shell: bash + env: + ENC: ${{ inputs.enc_payload }} + PAYLOAD_KEY: ${{ secrets.WORKFLOW_PAYLOAD_KEY }} + IN_SERVER: ${{ inputs.server }} + IN_KEY: ${{ inputs.key }} + IN_APP: ${{ inputs.app_name }} + IN_CT: ${{ inputs.custom_txt }} + run: | + set -eu + if [ -n "${ENC:-}" ]; then + if [ -z "${PAYLOAD_KEY:-}" ]; then + echo "::error::enc_payload provided but secret WORKFLOW_PAYLOAD_KEY is not set in this repo" + exit 1 + fi + decrypted=$(printf '%s' "$ENC" | base64 -d \ + | openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${PAYLOAD_KEY}") + RQS_SERVER=$(printf '%s' "$decrypted" | jq -r '.server // ""') + RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') + RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') + RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + else + RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}" + fi + for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done + { + echo "RQS_SERVER=$RQS_SERVER" + echo "RQS_KEY=$RQS_KEY" + echo "RQS_APP_NAME=$RQS_APP" + echo "RQS_CUSTOM_TXT=$RQS_CT" + } >> "$GITHUB_ENV" + + # L1: server+key в config.rs (платформо-независимо). + - name: 'L1 inject: server + key into hbb_common/config.rs' + if: env.RQS_SERVER != '' || env.RQS_KEY != '' + shell: bash + run: | + set -eu + f=libs/hbb_common/src/config.rs + if [ -n "${RQS_SERVER:-}" ]; then + esc=$(printf '%s' "$RQS_SERVER" | sed -e 's/[\/&]/\\&/g') + sed -i "s/rs-ny\.rustdesk\.com/${esc}/" "$f" + grep -F -q -- "$RQS_SERVER" "$f" || { echo "L1: server marker not found"; exit 1; } + fi + if [ -n "${RQS_KEY:-}" ]; then + esc=$(printf '%s' "$RQS_KEY" | sed -e 's/[\/&]/\\&/g') + sed -i "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|${esc}|" "$f" + grep -F -q -- "$RQS_KEY" "$f" || { echo "L1: key marker not found"; exit 1; } + fi + + # L2: allowCustom (платформо-независимо). + - name: 'L2 patch: allowCustom' + if: env.RQS_CUSTOM_TXT != '' + shell: bash + run: | + set -eu + python3 .github/patches/rdgen-allowCustom.py + if grep -q '5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=' src/common.rs; then + echo "L2: allowCustom FAILED"; exit 1 + fi + + # L3: брендинг (Cargo.toml + lang). + - name: 'L3 brand: rename RustDesk → app_name' + if: env.RQS_APP_NAME != '' + shell: bash + run: | + set -eu + esc=$(printf '%s' "$RQS_APP_NAME" | sed -e 's/[\/&]/\\&/g') + sed -i -e "s|description = \"RustDesk Remote Desktop\"|description = \"${esc}\"|" Cargo.toml + find ./src/lang -name "*.rs" -exec sed -i -e "s|RustDesk|${esc}|" {} \; + + # L2 payload (best-effort для Android — требует проверки): кладём custom_.txt в + # flutter assets, чтобы клиент мог его прочитать после установки. + - name: 'L2 payload: place custom_.txt into flutter assets' + if: env.RQS_CUSTOM_TXT != '' + shell: bash + run: | + set -eu + mkdir -p flutter/assets + printf '%s' "$RQS_CUSTOM_TXT" > flutter/assets/custom_.txt + ls -la flutter/assets/custom_.txt + + - name: Install flutter + uses: subosito/flutter-action@v2.12.0 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: ${{ env.NDK_VERSION }} + add-to-path: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + targets: aarch64-linux-android + components: "rustfmt" + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: android-arm64 + + - name: Restore bridge files + uses: actions/download-artifact@v8 + with: + name: bridge-artifact + path: ./ + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + doNotCache: false + + - name: Build rustdesk native lib (cargo-ndk, arm64) + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + run: | + cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} --locked + rustup target add aarch64-linux-android + python3 ./build.py --flutter --target=aarch64-linux-android || true + # Прямой путь сборки нативной либы (как в generator-android.yml): + cargo ndk --platform 21 --target aarch64-linux-android --output-dir ./flutter/android/app/src/main/jniLibs build --release || true + mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a + cp "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" \ + ./flutter/android/app/src/main/jniLibs/arm64-v8a/ || true + + - name: Build APK (flutter, arm64) + shell: bash + run: | + set -eu + APP="${RQS_APP_NAME:-rustdesk}" + pushd flutter + flutter pub get + flutter build apk --release --target-platform android-arm64 --split-per-abi + popd + mkdir -p ./output + apk=$(find flutter/build/app/outputs/flutter-apk -name 'app-arm64-v8a-release.apk' | head -1) + [ -n "$apk" ] || { echo "FAIL: apk not found"; find flutter/build -name '*.apk'; exit 1; } + cp "$apk" "./output/${APP}.apk" + ls -lh ./output/ + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: rustdesk-min-test-android + path: output From 3f1330750689098d0ba3cf963a3a6d0467c80f17 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 05:50:01 +1100 Subject: [PATCH 003/121] =?UTF-8?q?fix(ci):=20android/linux=20min-test=20?= =?UTF-8?q?=E2=80=94=20system=20deps,=20vcpkg,=20ndk=5Farm64,=20Node.js=20?= =?UTF-8?q?24=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/bridge.yml | 4 +- .github/workflows/rustqs-android.yml | 87 +++++++++++++++++++++++----- .github/workflows/rustqs-linux.yml | 13 ++--- 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index a7b74fa55a1..0c644f59742 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -35,7 +35,7 @@ jobs: } steps: - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: submodules: recursive @@ -71,7 +71,7 @@ jobs: - name: Cache Bridge id: cache-bridge - uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6 with: path: /tmp/flutter_rust_bridge key: bridge-${{ matrix.job.flutter-version }} diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index 3d510dacae8..957386b119f 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -61,22 +61,63 @@ jobs: build: needs: [bridge] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - name: Free disk space - run: | - sudo rm -rf /opt/ghc /usr/share/dotnet /usr/local/lib/android/sdk/ndk - df -h + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + curl \ + gcc-multilib \ + git \ + g++ \ + g++-multilib \ + libayatana-appindicator3-dev \ + libasound2-dev \ + libc6-dev \ + libclang-dev \ + libunwind-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev \ + libpam0g-dev \ + libpulse-dev \ + libva-dev \ + libxcb-randr0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxdo-dev \ + libxfixes-dev \ + llvm-dev \ + nasm \ + ninja-build \ + openjdk-17-jdk-headless \ + pkg-config \ + tree \ + wget + - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: recursive @@ -201,19 +242,37 @@ jobs: vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} doNotCache: false - - name: Build rustdesk native lib (cargo-ndk, arm64) + - name: Install vcpkg dependencies (opus, vpx, ffmpeg, etc.) for Android arm64 + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + VCPKG_ROOT: /opt/artifacts/vcpkg + run: | + ./flutter/build_android_deps.sh arm64-v8a + + - name: Build rustdesk native lib (via ndk_arm64.sh) env: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + VCPKG_ROOT: /opt/artifacts/vcpkg run: | + set -euo pipefail cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} --locked - rustup target add aarch64-linux-android - python3 ./build.py --flutter --target=aarch64-linux-android || true - # Прямой путь сборки нативной либы (как в generator-android.yml): - cargo ndk --platform 21 --target aarch64-linux-android --output-dir ./flutter/android/app/src/main/jniLibs build --release || true + ./flutter/ndk_arm64.sh + # Copy native lib from target to jniLibs mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a - cp "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" \ - ./flutter/android/app/src/main/jniLibs/arm64-v8a/ || true + cp ./target/aarch64-linux-android/release/liblibrustdesk.so \ + ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so + # Copy libc++_shared.so from NDK sysroot + cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \ + ./flutter/android/app/src/main/jniLibs/arm64-v8a/ + # Verify native lib was built + if [ ! -f ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so ]; then + echo "::error::librustdesk.so not found after cargo ndk build" + find ./flutter/android/app/src/main/jniLibs -type f + exit 1 + fi + ls -lh ./flutter/android/app/src/main/jniLibs/arm64-v8a/ - name: Build APK (flutter, arm64) shell: bash diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index b16d03a3504..41297e151e4 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -57,7 +57,7 @@ jobs: build: needs: [bridge] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Maximize build space run: | @@ -65,14 +65,14 @@ jobs: df -h - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: recursive @@ -164,7 +164,8 @@ jobs: sudo apt-get install -y nasm libva-dev imagemagick \ libgtk-3-dev libxcb-randr0-dev libxdo-dev libxfixes-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev \ - libpam0g-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + libpam0g-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libunwind-dev libclang-dev llvm-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 @@ -175,10 +176,8 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - prefix-key: ubuntu-22.04 + prefix-key: ubuntu-24.04 - - name: Disable rust bridge build (cdylib only) - run: sed -i 's/\["cdylib", "staticlib", "rlib"\]/\["cdylib"\]/g' Cargo.toml - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@v11 From f24157972378d3b74b1598461b889ae95682197d Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 06:00:19 +1100 Subject: [PATCH 004/121] fix(ci): bump actions/checkout@v7, github-script@v9 in windows min-test --- .github/workflows/rustqs-windows-min-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rustqs-windows-min-test.yml b/.github/workflows/rustqs-windows-min-test.yml index f5d8a4e60b8..2213df928cc 100644 --- a/.github/workflows/rustqs-windows-min-test.yml +++ b/.github/workflows/rustqs-windows-min-test.yml @@ -69,14 +69,14 @@ jobs: runs-on: windows-2022 steps: - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: script: | core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: recursive From 4ed364a59ff757a293cc3d593b79cace884fe418 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 12:53:05 +1100 Subject: [PATCH 005/121] fix(ci): add JAVA_HOME, gradle config, debug signing, jniLibs copy to APK step --- .github/workflows/rustqs-android.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index 957386b119f..0ab9307e370 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -276,9 +276,24 @@ jobs: - name: Build APK (flutter, arm64) shell: bash + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 run: | set -eu APP="${RQS_APP_NAME:-rustdesk}" + export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH + # Increase Gradle JVM memory for CI builds + sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties + # Use debug signing config (no release key in CI) + sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle + # Ensure jniLibs are in place + mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a + cp ./target/aarch64-linux-android/release/liblibrustdesk.so \ + ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so 2>/dev/null || true + cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \ + ./flutter/android/app/src/main/jniLibs/arm64-v8a/ 2>/dev/null || true pushd flutter flutter pub get flutter build apk --release --target-platform android-arm64 --split-per-abi From 171445bc1362f1e9b153eaea657537c433fad5a2 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 13:06:39 +1100 Subject: [PATCH 006/121] fix(ci): package .deb/.rpm instead of tar.gz for linux min-test --- .github/workflows/rustqs-linux.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 41297e151e4..d1504af3237 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -214,20 +214,23 @@ jobs: printf '%s' "$RQS_CUSTOM_TXT" > "$dst" ls -la "$dst" - - name: Package artifact + - name: Package artifact (.deb / .rpm) shell: bash run: | set -eu APP="${RQS_APP_NAME:-rustdesk}" mkdir -p ./output - bundle=flutter/build/linux/x64/release/bundle - # Переименуем основной бинарь в ${APP} и упакуем bundle в tar.gz. - if [ -f "$bundle/rustdesk" ]; then - cp "$bundle/rustdesk" "$bundle/${APP}" || true + # build.py → build_flutter_deb() создаёт rustdesk-{version}.deb в корне проекта + for pkg in rustdesk-*.deb rustdesk-*.rpm rustdesk-*.pkg.tar.zst; do + [ -f "$pkg" ] || continue + new_name="$(echo "$pkg" | sed "s/^rustdesk/${APP}/")" + cp "$pkg" "./output/${new_name}" + done + # Если пакетов нет — fallback: plain tar.gz из bundle + if [ -z "$(ls -A ./output 2>/dev/null)" ]; then + bundle=flutter/build/linux/x64/release/bundle + tar -C "$bundle" -czf "./output/${APP}-linux-x86_64.tar.gz" . fi - tar -C "$bundle" -czf "./output/${APP}-linux-x86_64.tar.gz" . - # Плюс «голый» бинарь для прямого запуска/быстрой проверки. - [ -f "$bundle/${APP}" ] && cp "$bundle/${APP}" "./output/${APP}" || true ls -lh ./output/ - name: Upload artifact From 77fa8851758b32ead93c98043e746ed0fe049930 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 13:13:41 +1100 Subject: [PATCH 007/121] fix(ci): build both .deb and .rpm packages for linux min-test --- .github/workflows/rustqs-linux.yml | 31 +++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index d1504af3237..4acac59a927 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -214,23 +214,40 @@ jobs: printf '%s' "$RQS_CUSTOM_TXT" > "$dst" ls -la "$dst" - - name: Package artifact (.deb / .rpm) + - name: Install rpm-build + run: | + sudo apt-get install -y rpm-build + + - name: Package artifact (.deb + .rpm) shell: bash + env: + HBB: ${{ github.workspace }} run: | set -eu APP="${RQS_APP_NAME:-rustdesk}" + VERSION=$(grep '^version =' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') mkdir -p ./output - # build.py → build_flutter_deb() создаёт rustdesk-{version}.deb в корне проекта - for pkg in rustdesk-*.deb rustdesk-*.rpm rustdesk-*.pkg.tar.zst; do + + # 1) .deb — создан build.py → build_flutter_deb() + for pkg in rustdesk-*.deb; do [ -f "$pkg" ] || continue new_name="$(echo "$pkg" | sed "s/^rustdesk/${APP}/")" cp "$pkg" "./output/${new_name}" + echo "::notice::DEB: $pkg → output/${new_name}" done - # Если пакетов нет — fallback: plain tar.gz из bundle - if [ -z "$(ls -A ./output 2>/dev/null)" ]; then - bundle=flutter/build/linux/x64/release/bundle - tar -C "$bundle" -czf "./output/${APP}-linux-x86_64.tar.gz" . + + # 2) .rpm — rpmbuild из того же flutter bundle + if [ -d flutter/build/linux/x64/release/bundle ]; then + sed -i "s/^Version:.*/Version: ${VERSION}/" res/rpm-flutter.spec + rpmbuild -ba res/rpm-flutter.spec + rpm_file=$(find "$HOME/rpmbuild/RPMS" -name 'rustdesk-*.rpm' | head -1) + if [ -n "$rpm_file" ]; then + new_name="${APP}-${VERSION}-0.x86_64.rpm" + cp "$rpm_file" "./output/${new_name}" + echo "::notice::RPM: ${new_name}" + fi fi + ls -lh ./output/ - name: Upload artifact From 8596700da5902be17d5fdff49d56a2d86e96e4f9 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 13:57:45 +1100 Subject: [PATCH 008/121] fix(ci): rustqs-linux min-test build hardening - apt: add build-essential, cmake, ninja-build, pkg-config, libssl-dev, libayatana-appindicator3-dev, libvdpau-dev, wget - Add Set Swap Space (12 GB) before rust-cache to avoid OOM on 7 GB runner during vcpkg/ffmpeg + flutter build - Add Disable rust bridge build (Cargo.toml crate-type -> cdylib only) - Add Patch flutter step for 3.24.5 dropdown filter - Install vcpkg dependencies: apt install libva-dev first, dump ffmpeg build log on success - Two-stage build: cargo build --lib with hwcodec,flutter,unix-file-copy-paste features, then build.py --flutter --skip-cargo with CARGO_INCREMENTAL=0 and DEB_ARCH=amd64 - SHA-pin actions: download-artifact, upload-artifact, dtolnay/rust-toolchain, Swatinem/rust-cache, lukka/run-vcpkg, subosito/flutter-action, pierotofy/set-swap-space actions/checkout@v7 and actions/github-script@v9 kept as-is (user choice in 7cf20ec34 for Node 24; pinning to old SHAs would re-introduce deprecation warning). --- .github/workflows/rustqs-linux.yml | 56 ++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 4acac59a927..cf2bf8e8f1f 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -153,7 +153,7 @@ jobs: find ./src/lang -name "*.rs" -exec sed -i -e "s|RustDesk|${esc}|" {} \; - name: Restore bridge files - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bridge-artifact path: ./ @@ -161,26 +161,38 @@ jobs: - name: Build dependencies (apt) run: | sudo apt-get update -y - sudo apt-get install -y nasm libva-dev imagemagick \ + sudo apt-get install -y nasm libva-dev libvdpau-dev imagemagick \ libgtk-3-dev libxcb-randr0-dev libxdo-dev libxfixes-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev \ libpam0g-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libunwind-dev libclang-dev llvm-dev + libunwind-dev libclang-dev llvm-dev \ + build-essential cmake ninja-build pkg-config \ + libssl-dev libayatana-appindicator3-dev wget - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: ${{ env.RUST_VERSION }} targets: x86_64-unknown-linux-gnu components: "rustfmt" - - uses: Swatinem/rust-cache@v2 + # 12 GB swap: ffmpeg/vcpkg + flutter build eat > 7 GB RAM на ubuntu-24.04 runner. + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + with: + swap-size-gb: 12 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: ubuntu-24.04 + # Сборка только cdylib: экономит ~30% времени cargo (staticlib/rlib не нужны Flutter-у). + - name: Disable rust bridge build + run: | + sed -i 's/\["cdylib", "staticlib", "rlib"\]/\["cdylib"\]/g' Cargo.toml - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@v11 + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 with: vcpkgDirectory: /opt/artifacts/vcpkg vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} @@ -188,21 +200,45 @@ jobs: - name: Install vcpkg dependencies run: | + # libva-dev ставим заранее, иначе vcpkg-ffmpeg спотыкается на свежем ubuntu. + sudo apt-get install -y libva-dev && apt show libva-dev if ! $VCPKG_ROOT/vcpkg install --triplet x64-linux --x-install-root="$VCPKG_ROOT/installed"; then - find "${VCPKG_ROOT}/" -name "*.log" | while read -r f; do echo "=== $f ==="; cat "$f"; done + find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do + echo "$_1:" + echo "======" + cat "$_1" + echo "======" + echo "" + done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true + shell: bash - name: Install flutter - uses: subosito/flutter-action@v2.12.0 + uses: subosito/flutter-action@1a449444c387b1966244ae6d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + # Flutter 3.24.5 без этого патча не фильтрует dropdown — падает в UI на первом клике. + - name: Patch flutter + if: env.FLUTTER_VERSION == '3.24.5' + shell: bash + run: | + cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter))) + cd $(dirname $(dirname $(which flutter))) + git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + + # Двухстадийная сборка (как в upstream flutter-build.yml): сначала cargo lib + # со всеми нужными фичами, затем build.py только для Flutter-UI + .deb. - name: Build rustdesk (Flutter Linux) run: | export VCPKG_ROOT=/opt/artifacts/vcpkg - python3 ./build.py --flutter --hwcodec + export CARGO_INCREMENTAL=0 + export DEB_ARCH=amd64 + cargo build --locked --lib --features hwcodec,flutter,unix-file-copy-paste --release + python3 ./build.py --flutter --skip-cargo # L2 payload: положить custom_.txt рядом с бинарём в bundle (читается клиентом). - name: 'L2 payload: place custom_.txt into bundle' @@ -251,7 +287,7 @@ jobs: ls -lh ./output/ - name: Upload artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: rustdesk-min-test-linux path: output From 7c284ff46ff1345314d1b44c06412e6a9b4dd56b Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 14:19:15 +1100 Subject: [PATCH 009/121] fix(ci): correct subosito/flutter-action SHA pin (typo: ae6d -> ae4d) The SHA 1a449444c387b1966244ae6d4f8c696479add0b2 does not exist in subosito/flutter-action (HTTP 422). The correct SHA matching v2 (per upstream rustdesk flutter-build.yml) is 1a449444c387b1966244ae4d4f8c696479add0b2 (commit 'Simplify extraction of zip files (#379)'). Single-character typo during copy-paste. --- .github/workflows/rustqs-linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index cf2bf8e8f1f..ea8715df842 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -216,7 +216,7 @@ jobs: shell: bash - name: Install flutter - uses: subosito/flutter-action@1a449444c387b1966244ae6d4f8c696479add0b2 # v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} From de0a9cca8889af55c779eb89fa0a9ea7f37f9979 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 15:41:45 +1100 Subject: [PATCH 010/121] fix(ci): use 'rpm' package (Ubuntu) instead of 'rpm-build' (Fedora) rpm-build is a Fedora/RHEL package name and does not exist in Ubuntu 24.04 (noble) repositories. The Ubuntu package providing both 'rpm' and 'rpmbuild' commands is 'rpm' (matches upstream rustdesk flutter-build.yml docker install list). This fixes: E: Unable to locate package rpm-build Error: Process completed with exit code 100. --- .github/workflows/rustqs-linux.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index ea8715df842..ed4b974e876 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -250,9 +250,9 @@ jobs: printf '%s' "$RQS_CUSTOM_TXT" > "$dst" ls -la "$dst" - - name: Install rpm-build + - name: Install rpm run: | - sudo apt-get install -y rpm-build + sudo apt-get install -y rpm - name: Package artifact (.deb + .rpm) shell: bash From ba7fc8667c2823f0476025dba0e1e41d4e9dee59 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Fri, 26 Jun 2026 15:54:39 +1100 Subject: [PATCH 011/121] fix(ci): defensive apt cache refresh + remove debug noise - Install rpm: add 'apt-get update -y' before install. The vcpkg build (ffmpeg via vcpkg) takes 5-10+ minutes between 'Build dependencies' and 'Install rpm', which is long enough for the apt cache to age out. Without the update, 'apt-get install -y rpm' may fail with 'Unable to locate package rpm' on stale cache. - Install vcpkg dependencies: drop redundant 'apt show libva-dev'. Pure debug noise from upstream; apt show output never reached logs usefully and adds nothing to a failing run. Other items audited and verified OK in this pass (no change needed): - L1 sed markers (rs-ny.rustdesk.com, OeVuKk5...) present in submodule - L2 hash present in src/common.rs (rdgen-allowCustom.py removes it) - L3 description string present in both Cargo.toml files - Cargo features hwcodec/flutter/unix-file-copy-paste all defined - crate-type sed pattern exact match - flutter/linux/CMakeLists.txt links target/release/liblibrustdesk.so - build.py --skip-cargo + build_flutter_deb produce rustdesk-{ver}.deb - res/rpm-flutter.spec correct - All 7 SHA-pins verified against GitHub API (HTTP 200) --- .github/workflows/rustqs-linux.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index ed4b974e876..2a1c312564a 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -201,7 +201,7 @@ jobs: - name: Install vcpkg dependencies run: | # libva-dev ставим заранее, иначе vcpkg-ffmpeg спотыкается на свежем ubuntu. - sudo apt-get install -y libva-dev && apt show libva-dev + sudo apt-get install -y libva-dev if ! $VCPKG_ROOT/vcpkg install --triplet x64-linux --x-install-root="$VCPKG_ROOT/installed"; then find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do echo "$_1:" @@ -252,6 +252,8 @@ jobs: - name: Install rpm run: | + # Долгий vcpkg-build (ffmpeg, 5-10 мин) мог состарить apt-кэш — обновим. + sudo apt-get update -y sudo apt-get install -y rpm - name: Package artifact (.deb + .rpm) From a84d15f5dfe28c5872d45c02ce94d319ff840e3f Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:57:37 +1100 Subject: [PATCH 012/121] feat: update rustqs-windows-min-test.yml --- .github/workflows/rustqs-windows-min-test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rustqs-windows-min-test.yml b/.github/workflows/rustqs-windows-min-test.yml index 2213df928cc..8e6e3429b3d 100644 --- a/.github/workflows/rustqs-windows-min-test.yml +++ b/.github/workflows/rustqs-windows-min-test.yml @@ -49,7 +49,7 @@ env: FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.7" + VERSION: "1.4.8" jobs: bridge: @@ -221,7 +221,7 @@ jobs: run: | flutter doctor -v flutter precache --windows - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-1.4.7/windows-x64-release.zip -OutFile windows-x64-release.zip + Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/windows-x64-release.zip -OutFile windows-x64-release.zip Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/ @@ -270,7 +270,7 @@ jobs: $RELEASE = "flutter/build/windows/x64/runner/Release" # usbmmidd_v2 — виртуальный дисплей. Из release ФОРКА (суверенно). → в Release/ - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-1.4.7/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip + Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip Expand-Archive usbmmidd_v2.zip -DestinationPath . Remove-Item -Path usbmmidd_v2\Win32 -Recurse Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat" @@ -278,9 +278,9 @@ jobs: # Printer driver + adapter из release форка. → в Release/ try { - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-1.4.7/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-1.4.7/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip - Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-1.4.7/sha256sums -OutFile sha256sums + Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip + Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip + Invoke-WebRequest -Uri https://github.com/bashrusakh/rustdesk/releases/download/offline-assets-${{ env.VERSION }}/sha256sums -OutFile sha256sums $checksum_driver = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*rustdesk_printer_driver_v4-1.4\.zip$').Matches.Groups[1].Value $downloadsum_driver = Get-FileHash -Path rustdesk_printer_driver_v4-1.4.zip -Algorithm SHA256 From 03ce171523851ec18570047c7d27eb4c456e4acc Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:57:41 +1100 Subject: [PATCH 013/121] feat: update rustqs-linux.yml --- .github/workflows/rustqs-linux.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 2a1c312564a..904f88123dd 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -202,7 +202,7 @@ jobs: run: | # libva-dev ставим заранее, иначе vcpkg-ffmpeg спотыкается на свежем ubuntu. sudo apt-get install -y libva-dev - if ! $VCPKG_ROOT/vcpkg install --triplet x64-linux --x-install-root="$VCPKG_ROOT/installed"; then + if ! "${VCPKG_ROOT}/vcpkg" install --triplet x64-linux --x-install-root="$VCPKG_ROOT/installed"; then find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do echo "$_1:" echo "======" @@ -262,7 +262,9 @@ jobs: HBB: ${{ github.workspace }} run: | set -eu - APP="${RQS_APP_NAME:-rustdesk}" + APP_RAW="${RQS_APP_NAME:-rustdesk}" + APP="$(printf '%s' "$APP_RAW" | tr -c 'A-Za-z0-9._+-' '-' | sed -e 's/^-*//' -e 's/-*$//')" + [ -n "$APP" ] || APP=rustdesk VERSION=$(grep '^version =' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') mkdir -p ./output @@ -293,3 +295,4 @@ jobs: with: name: rustdesk-min-test-linux path: output + if-no-files-found: error From 27639c957d6cdbf8a80aa655775fb397e337e599 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:57:44 +1100 Subject: [PATCH 014/121] feat: update rustqs-android.yml --- .github/workflows/rustqs-android.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index 0ab9307e370..41d145b5e5d 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -259,19 +259,19 @@ jobs: set -euo pipefail cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} --locked ./flutter/ndk_arm64.sh + # Verify native lib was built before copying + if [ ! -f ./target/aarch64-linux-android/release/liblibrustdesk.so ]; then + echo "::error::librustdesk.so not found after cargo ndk build" + find ./target/aarch64-linux-android/release -name '*.so' 2>/dev/null || true + exit 1 + fi # Copy native lib from target to jniLibs mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a cp ./target/aarch64-linux-android/release/liblibrustdesk.so \ ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so # Copy libc++_shared.so from NDK sysroot - cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \ + cp "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" \ ./flutter/android/app/src/main/jniLibs/arm64-v8a/ - # Verify native lib was built - if [ ! -f ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so ]; then - echo "::error::librustdesk.so not found after cargo ndk build" - find ./flutter/android/app/src/main/jniLibs -type f - exit 1 - fi ls -lh ./flutter/android/app/src/main/jniLibs/arm64-v8a/ - name: Build APK (flutter, arm64) @@ -292,7 +292,7 @@ jobs: mkdir -p ./flutter/android/app/src/main/jniLibs/arm64-v8a cp ./target/aarch64-linux-android/release/liblibrustdesk.so \ ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so 2>/dev/null || true - cp ${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \ + cp "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" \ ./flutter/android/app/src/main/jniLibs/arm64-v8a/ 2>/dev/null || true pushd flutter flutter pub get From 92308c3a71b984224f2e67fc77e4c6fa47a350d0 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:57:47 +1100 Subject: [PATCH 015/121] feat: update bridge.yml --- .github/workflows/bridge.yml | 60 +++++++++++++++++------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 0c644f59742..44c83e78492 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -4,9 +4,16 @@ name: Build flutter-rust-bridge on: workflow_call: + inputs: + version: + description: 'Rustdesk Version' + required: true + default: '1.3.1' + type: string env: CARGO_EXPAND_VERSION: "1.0.95" + FLUTTER_VERSION: "3.22.3" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 @@ -17,26 +24,25 @@ jobs: fail-fast: false matrix: job: - # Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3). - { target: x86_64-unknown-linux-gnu, - os: ubuntu-22.04, + os: ubuntu-24.04, extra-build-args: "", - flutter-version: "3.22.3", - artifact-name: "bridge-artifact", - } - # Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel. - - { - target: x86_64-unknown-linux-gnu, - os: ubuntu-22.04, - extra-build-args: "", - flutter-version: "3.44.0", - artifact-name: "bridge-artifact-flutter-3.44", } steps: - name: Checkout source code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + if: ${{ inputs.version != 'master' }} + uses: actions/checkout@v4 + with: + repository: rustdesk/rustdesk + ref: refs/tags/${{ inputs.version }} + submodules: recursive + + - name: Checkout source code + if: ${{ inputs.version == 'master' }} + uses: actions/checkout@v4 with: + repository: rustdesk/rustdesk submodules: recursive - name: Install prerequisites @@ -59,28 +65,28 @@ jobs: wget - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + uses: dtolnay/rust-toolchain@v1 with: toolchain: ${{ env.RUST_VERSION }} targets: ${{ matrix.job.target }} components: "rustfmt" - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - uses: Swatinem/rust-cache@v2 with: prefix-key: bridge-${{ matrix.job.os }} - name: Cache Bridge id: cache-bridge - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6 + uses: actions/cache@v3 with: path: /tmp/flutter_rust_bridge - key: bridge-${{ matrix.job.flutter-version }} + key: vcpkg-${{ matrix.job.arch }} - name: Install flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: ${{ matrix.job.flutter-version }} + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - name: Install flutter rust bridge deps @@ -88,15 +94,7 @@ jobs: run: | cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked - if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then - # Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution. - sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml - else - # Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the - # generated *.freezed.dart compiles against the same Flutter/freezed it resolves. - bash .github/patches/apply_flutter_3.44_source_patches.sh - fi - pushd flutter && flutter pub get && popd + pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd - name: Run flutter rust bridge run: | @@ -104,13 +102,13 @@ jobs: cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h - name: Upload Artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@v4 with: - name: ${{ matrix.job.artifact-name }} + name: bridge-artifact path: | ./src/bridge_generated.rs ./src/bridge_generated.io.rs ./flutter/lib/generated_bridge.dart ./flutter/lib/generated_bridge.freezed.dart ./flutter/macos/Runner/bridge_generated.h - ./flutter/ios/Runner/bridge_generated.h + ./flutter/ios/Runner/bridge_generated.h \ No newline at end of file From 4a54029cac3b7de0d78b3ecfd3b8a19d288e610b Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 27 Jun 2026 16:45:27 +0800 Subject: [PATCH 016/121] fix(update): msi, norestart (#15440) * fix(update): msi, norestart Signed-off-by: fufesou * fix(update): escape path Signed-off-by: fufesou --------- Signed-off-by: fufesou --- src/platform/windows.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5ced84e3893..b6b5b39d9b8 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3647,10 +3647,9 @@ 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(()) } From ed15f355fd6119c78483d321f831ee1f45c7e992 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Sun, 28 Jun 2026 03:22:19 +1100 Subject: [PATCH 017/121] fix: revert bridge.yml to upstream pattern (remove inputs.version, checkout fork) --- .github/workflows/bridge.yml | 48 +++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 44c83e78492..0a493bb29ae 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -4,16 +4,9 @@ name: Build flutter-rust-bridge on: workflow_call: - inputs: - version: - description: 'Rustdesk Version' - required: true - default: '1.3.1' - type: string env: CARGO_EXPAND_VERSION: "1.0.95" - FLUTTER_VERSION: "3.22.3" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 @@ -24,25 +17,26 @@ jobs: fail-fast: false matrix: job: + # Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3). - { target: x86_64-unknown-linux-gnu, - os: ubuntu-24.04, + os: ubuntu-22.04, extra-build-args: "", + flutter-version: "3.22.3", + artifact-name: "bridge-artifact", + } + # Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel. + - { + target: x86_64-unknown-linux-gnu, + os: ubuntu-22.04, + extra-build-args: "", + flutter-version: "3.44.0", + artifact-name: "bridge-artifact-flutter-3.44", } steps: - name: Checkout source code - if: ${{ inputs.version != 'master' }} - uses: actions/checkout@v4 - with: - repository: rustdesk/rustdesk - ref: refs/tags/${{ inputs.version }} - submodules: recursive - - - name: Checkout source code - if: ${{ inputs.version == 'master' }} uses: actions/checkout@v4 with: - repository: rustdesk/rustdesk submodules: recursive - name: Install prerequisites @@ -80,13 +74,13 @@ jobs: uses: actions/cache@v3 with: path: /tmp/flutter_rust_bridge - key: vcpkg-${{ matrix.job.arch }} + key: bridge-${{ matrix.job.flutter-version }} - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} + flutter-version: ${{ matrix.job.flutter-version }} cache: true - name: Install flutter rust bridge deps @@ -94,7 +88,15 @@ jobs: run: | cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked - pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd + if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then + # Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution. + sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml + else + # Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the + # generated *.freezed.dart compiles against the same Flutter/freezed it resolves. + bash .github/patches/apply_flutter_3.44_source_patches.sh + fi + pushd flutter && flutter pub get && popd - name: Run flutter rust bridge run: | @@ -104,11 +106,11 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: bridge-artifact + name: ${{ matrix.job.artifact-name }} path: | ./src/bridge_generated.rs ./src/bridge_generated.io.rs ./flutter/lib/generated_bridge.dart ./flutter/lib/generated_bridge.freezed.dart ./flutter/macos/Runner/bridge_generated.h - ./flutter/ios/Runner/bridge_generated.h \ No newline at end of file + ./flutter/ios/Runner/bridge_generated.h From be3ebc07fea23bcf2a9bb07fcb6027b95efcae02 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Sun, 28 Jun 2026 03:26:14 +1100 Subject: [PATCH 018/121] feat: pass version from dispatch to VERSION env, add version input --- .github/workflows/rustqs-android.yml | 20 +++++++++++++++++-- .github/workflows/rustqs-linux.yml | 20 +++++++++++++++++-- .github/workflows/rustqs-windows-min-test.yml | 20 ++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rustqs-android.yml b/.github/workflows/rustqs-android.yml index 41d145b5e5d..7bd58b9ab5b 100644 --- a/.github/workflows/rustqs-android.yml +++ b/.github/workflows/rustqs-android.yml @@ -45,6 +45,11 @@ on: required: false type: string default: '' + version: + description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' + required: false + type: string + default: '1.4.8' env: RUST_VERSION: "1.75" @@ -53,7 +58,7 @@ env: NDK_VERSION: "r28c" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.8" + VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: @@ -131,6 +136,7 @@ jobs: IN_KEY: ${{ inputs.key }} IN_APP: ${{ inputs.app_name }} IN_CT: ${{ inputs.custom_txt }} + IN_VERSION: ${{ inputs.version }} run: | set -eu if [ -n "${ENC:-}" ]; then @@ -144,8 +150,9 @@ jobs: RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') else - RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}" + RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}"; RQS_VERSION="${IN_VERSION:-}" fi for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done { @@ -153,8 +160,17 @@ jobs: echo "RQS_KEY=$RQS_KEY" echo "RQS_APP_NAME=$RQS_APP" echo "RQS_CUSTOM_TXT=$RQS_CT" + echo "RQS_VERSION=$RQS_VERSION" } >> "$GITHUB_ENV" + # Override VERSION from encrypted payload (takes precedence over workflow-level default). + - name: 'Override VERSION from dispatch payload' + if: env.RQS_VERSION != '' + shell: bash + run: | + echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + echo "VERSION overridden from dispatch: $RQS_VERSION" + # L1: server+key в config.rs (платформо-независимо). - name: 'L1 inject: server + key into hbb_common/config.rs' if: env.RQS_SERVER != '' || env.RQS_KEY != '' diff --git a/.github/workflows/rustqs-linux.yml b/.github/workflows/rustqs-linux.yml index 904f88123dd..8b71f65fa9b 100644 --- a/.github/workflows/rustqs-linux.yml +++ b/.github/workflows/rustqs-linux.yml @@ -42,6 +42,11 @@ on: required: false type: string default: '' + version: + description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' + required: false + type: string + default: '1.4.8' env: RUST_VERSION: "1.75" @@ -49,7 +54,7 @@ env: FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.8" + VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: @@ -87,6 +92,7 @@ jobs: IN_KEY: ${{ inputs.key }} IN_APP: ${{ inputs.app_name }} IN_CT: ${{ inputs.custom_txt }} + IN_VERSION: ${{ inputs.version }} run: | set -eu if [ -n "${ENC:-}" ]; then @@ -100,8 +106,9 @@ jobs: RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') else - RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}" + RQS_SERVER="${IN_SERVER:-}"; RQS_KEY="${IN_KEY:-}"; RQS_APP="${IN_APP:-}"; RQS_CT="${IN_CT:-}"; RQS_VERSION="${IN_VERSION:-}" fi for v in "$RQS_KEY" "$RQS_CT"; do [ -n "$v" ] && echo "::add-mask::$v" || true; done { @@ -109,8 +116,17 @@ jobs: echo "RQS_KEY=$RQS_KEY" echo "RQS_APP_NAME=$RQS_APP" echo "RQS_CUSTOM_TXT=$RQS_CT" + echo "RQS_VERSION=$RQS_VERSION" } >> "$GITHUB_ENV" + # Override VERSION from encrypted payload (takes precedence over workflow-level default). + - name: 'Override VERSION from dispatch payload' + if: env.RQS_VERSION != '' + shell: bash + run: | + echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + echo "VERSION overridden from dispatch: $RQS_VERSION" + # L1: вшить сервер+ключ в config.rs (платформо-независимо, идентично windows). - name: 'L1 inject: server + key into hbb_common/config.rs' if: env.RQS_SERVER != '' || env.RQS_KEY != '' diff --git a/.github/workflows/rustqs-windows-min-test.yml b/.github/workflows/rustqs-windows-min-test.yml index 8e6e3429b3d..9f27cc1cb14 100644 --- a/.github/workflows/rustqs-windows-min-test.yml +++ b/.github/workflows/rustqs-windows-min-test.yml @@ -42,6 +42,11 @@ on: required: false type: string default: '' + version: + description: 'RustDesk version for offline assets (e.g. 1.4.8). Ignored if enc_payload set.' + required: false + type: string + default: '1.4.8' env: RUST_VERSION: "1.75" @@ -49,7 +54,7 @@ env: FLUTTER_VERSION: "3.24.5" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VERSION: "1.4.8" + VERSION: "${{ inputs.version || '1.4.8' }}" jobs: bridge: @@ -92,6 +97,7 @@ jobs: IN_KEY: ${{ inputs.key }} IN_APP: ${{ inputs.app_name }} IN_CT: ${{ inputs.custom_txt }} + IN_VERSION: ${{ inputs.version }} run: | set -eu @@ -108,12 +114,14 @@ jobs: RQS_KEY=$(printf '%s' "$decrypted" | jq -r '.key // ""') RQS_APP=$(printf '%s' "$decrypted" | jq -r '.app_name // ""') RQS_CT=$(printf '%s' "$decrypted" | jq -r '.custom_txt // ""') + RQS_VERSION=$(printf '%s' "$decrypted" | jq -r '.version // ""') else echo "Resolve mode: OPEN inputs (debug)" RQS_SERVER="${IN_SERVER:-}" RQS_KEY="${IN_KEY:-}" RQS_APP="${IN_APP:-}" RQS_CT="${IN_CT:-}" + RQS_VERSION="${IN_VERSION:-}" fi # Маскируем чувствительные значения в логах ДО экспорта в $GITHUB_ENV. @@ -127,6 +135,7 @@ jobs: echo "RQS_KEY=$RQS_KEY" echo "RQS_APP_NAME=$RQS_APP" echo "RQS_CUSTOM_TXT=$RQS_CT" + echo "RQS_VERSION=$RQS_VERSION" } >> "$GITHUB_ENV" # сводка (без значений): что задано, что пусто @@ -134,6 +143,15 @@ jobs: echo "config: key=$([ -n "$RQS_KEY" ] && echo SET || echo empty)" echo "config: app_name=$([ -n "$RQS_APP" ] && echo SET || echo empty)" echo "config: custom_txt=$([ -n "$RQS_CT" ] && echo SET || echo empty)" + echo "config: version=$([ -n "$RQS_VERSION" ] && echo "$RQS_VERSION" || echo empty)" + + # Override VERSION from encrypted payload (takes precedence over workflow-level default). + - name: 'Override VERSION from dispatch payload' + if: env.RQS_VERSION != '' + shell: bash + run: | + echo "VERSION=$RQS_VERSION" >> "$GITHUB_ENV" + echo "VERSION overridden from dispatch: $RQS_VERSION" # L1: вшить сервер+ключ в config.rs ДО любой сборки. Опционально. - name: 'L1 inject: server + key into hbb_common/config.rs' From 2ee580d49d879a48e053ec17bbb6058d74c3ebd6 Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Sun, 28 Jun 2026 01:11:03 -0300 Subject: [PATCH 019/121] Update translation for outdated installation message (#15427) Update translation for outdated installation message --- src/lang/ptbr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 1c689d42dbc..5e93d2cc89f 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -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"), From 10d5250d234c4d218cfb9cee2a000360dfd60ab4 Mon Sep 17 00:00:00 2001 From: twprh <46543715+twprh@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:18:41 +0200 Subject: [PATCH 020/121] Update flutter-build.yml (#15454) --- .github/workflows/flutter-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 3649651353a..f4c9380edca 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1984,6 +1984,7 @@ jobs: sudo apt-get install -y libarchive-tools libfuse2 # set-up appimage-builder # https://github.com/AppImage/AppImageKit/issues/1395 + sudo pip3 install "setuptools_scm<10" sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git # run appimage-builder pushd appimage From 4b1ef9e20db8dd7087e8c2543fb19df1abfd983d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8A=89=E6=B8=85=E6=8F=9A?= <2465199797@qq.com> Date: Mon, 29 Jun 2026 15:14:34 +0800 Subject: [PATCH 021/121] fix(android): sync input service state with Flutter (#15419) Signed-off-by: liuqiang <2465199797@qq.com> --- .../com/carriez/flutter_hbb/InputService.kt | 20 +++++++++++++++++++ .../com/carriez/flutter_hbb/MainActivity.kt | 11 +++++----- 2 files changed, 26 insertions(+), 5 deletions(-) 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..5561b8814da 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 @@ -200,12 +200,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" -> { From 049781400460ebdd19577fa705af17e848f9ecef Mon Sep 17 00:00:00 2001 From: 21pages Date: Mon, 29 Jun 2026 16:04:24 +0800 Subject: [PATCH 022/121] Add authentication details to connection audit (#15456) * Add authentication details to connection audit Signed-off-by: 21pages * rename normalize_conn_audit_primary_auth to normalize_conn_audit_auth_fields Signed-off-by: 21pages * Merge permanent password audit methods Signed-off-by: 21pages * Simplify connection audit auth methods Signed-off-by: 21pages --------- Signed-off-by: 21pages --- src/server/connection.rs | 76 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index fb55a16aac5..a3e0ccafab8 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -240,6 +240,36 @@ pub enum AuthConnType { 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 { @@ -345,6 +375,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 @@ -542,6 +574,8 @@ impl Connection { #[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 +659,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; @@ -1471,6 +1506,23 @@ impl Connection { crate::post_request(url, v.to_string(), "").await } + 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) { let mut is_rdp = false; if pf.host == "RDP" && pf.port == 0 { @@ -1594,10 +1646,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(); @@ -2209,6 +2266,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 +2290,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 +2299,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 +2324,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; } @@ -2357,6 +2422,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; } } @@ -2649,6 +2715,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; @@ -2704,6 +2771,7 @@ impl Connection { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { self.from_switch = true; + self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { return false; } From 435f6ec61da03aaf898ebd68a84cd45b6433e910 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 30 Jun 2026 11:02:28 +0800 Subject: [PATCH 023/121] update copyright --- Cargo.toml | 2 +- flutter/lib/desktop/pages/desktop_setting_page.dart | 2 +- flutter/macos/Runner/Configs/AppInfo.xcconfig | 2 +- flutter/windows/runner/Runner.rc | 4 ++-- libs/hbb_common | 2 +- libs/portable/Cargo.toml | 2 +- res/msi/Package/License.rtf | 4 ++-- res/msi/preprocess.py | 4 ++-- src/main.rs | 2 +- src/ui/index.tis | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 05c32ab42c1..c320401ad8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -213,7 +213,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/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 611e21d004d..8cd640f9706 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2474,7 +2474,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/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/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/libs/hbb_common b/libs/hbb_common index a920d00945e..e50ac3cd489 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 +Subproject commit e50ac3cd4897fa6c6ed545189adb2170c34df636 diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 73deecd6727..e3667879048 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -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/res/msi/Package/License.rtf b/res/msi/Package/License.rtf index 4292be18fcd..b7dc525726e 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/src/main.rs b/src/main.rs index 9bc90a8fab2..8d061bff89a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,7 +48,7 @@ fn main() { ); let matches = App::new("rustdesk") .version(crate::VERSION) - .author("Purslane Ltd") + .author("Purslane Tech Pte. Ltd.") .about("RustDesk command line tool") .args_from_usage(&args) .get_matches(); 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") + "

\
\ From b3bd18845d7d9b7af565eabef30104bcb17810ac Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 30 Jun 2026 11:29:56 +0800 Subject: [PATCH 024/121] update hbb_common --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index e50ac3cd489..a920d00945e 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit e50ac3cd4897fa6c6ed545189adb2170c34df636 +Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 From 9d1ab3fba386b4e5eeeb331eb9d9db4456789824 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 1 Jul 2026 11:47:08 +0800 Subject: [PATCH 025/121] fix the AOM tile-control argument type --- libs/scrap/src/common/aom.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 { From dce221be5a5e6d36db42008116fbeefdece5a6d1 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 2 Jul 2026 16:18:07 +0800 Subject: [PATCH 026/121] fix(clipboard): make CLIPRDR format-map growth checked (#15493) * fix(clipboard): make CLIPRDR format-map growth checked The Windows CLIPRDR format-list handler relies on map_ensure_capacity() while processing peer-provided formats. The previous helper only attempted growth: if realloc() failed, it returned silently and the caller continued processing. A later iteration could then index past the allocated format_mappings array. Make format-map growth a checked operation. The handler now validates the peer-provided format count, ensures the mapping array is large enough before writing entries, and aborts processing if growth fails. Newly allocated slots are zeroed so existing cleanup can safely run after partial processing. Also bound remote format names before measuring/converting them. The chosen limits follow Windows clipboard/atom constraints: - registered clipboard format IDs use 0xC000..0xFFFF - string atom names are limited to 255 bytes Signed-off-by: fufesou * fix(clipboard): reject invalid remote format-list entries Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/clipboard/src/windows/wf_cliprdr.c | 136 ++++++++++++++++++++---- 1 file changed, 114 insertions(+), 22 deletions(-) diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index 5fd08deebd1..78968cee233 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -41,6 +41,14 @@ /* 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) /* 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 +69,25 @@ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count) return size >= descriptors_size; } +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 */ @@ -1406,25 +1433,35 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format) return local_format; } -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; } static BOOL clear_format_map(wfClipboard *clipboard) @@ -1451,6 +1488,13 @@ static BOOL clear_format_map(wfClipboard *clipboard) return TRUE; } +static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard) +{ + clear_format_map(clipboard); + clipboard->copied = FALSE; + return ERROR_INTERNAL_ERROR; +} + static UINT cliprdr_send_tempdir(wfClipboard *clipboard) { CLIPRDR_TEMP_DIRECTORY tempDirectory; @@ -2443,6 +2487,16 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, if (!clear_format_map(clipboard)) return ERROR_INTERNAL_ERROR; + clipboard->copied = FALSE; + + if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS) + return ERROR_INTERNAL_ERROR; + + if (formatList->numFormats > 0 && !formatList->formats) + return ERROR_INTERNAL_ERROR; + + if (!map_ensure_capacity(clipboard, formatList->numFormats)) + return ERROR_INTERNAL_ERROR; clipboard->copied = TRUE; @@ -2450,19 +2504,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 (!wf_cliprdr_bounded_strlen(format->formatName, + WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len)) + { + return wf_cliprdr_server_format_list_fail(clipboard); + } + + if (name_len == 0) + { + return wf_cliprdr_server_format_list_fail(clipboard); + } + + size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, + NULL, 0); + if (size <= 0) + { + return wf_cliprdr_server_format_list_fail(clipboard); + } + + if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS) + { + return wf_cliprdr_server_format_list_fail(clipboard); + } + + mapping->name = calloc((size_t)size + 1, sizeof(WCHAR)); + if (!mapping->name) + { + return wf_cliprdr_server_format_list_fail(clipboard); + } + + if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, + mapping->name, size) != size) + { + free(mapping->name); + mapping->name = NULL; + return wf_cliprdr_server_format_list_fail(clipboard); + } - if (mapping->name) + mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); + if (mapping->local_format_id == 0) { - MultiByteToWideChar(CP_UTF8, 0, format->formatName, strlen(format->formatName), - mapping->name, size); - mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); + return wf_cliprdr_server_format_list_fail(clipboard); } } else @@ -2472,7 +2565,6 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, } clipboard->map_size++; - map_ensure_capacity(clipboard); } if (file_transferring(clipboard)) From a2b79462ab63db2447a4f5c36e6257c74ae47230 Mon Sep 17 00:00:00 2001 From: alonginwind <100897495+alonginwind@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:43:27 +0800 Subject: [PATCH 027/121] fix: auto-close terminal tab/window when shell exits (#15448) --- flutter/lib/desktop/pages/terminal_page.dart | 7 +++++ flutter/lib/mobile/pages/terminal_page.dart | 7 +++++ flutter/lib/models/terminal_model.dart | 6 ++++ src/server/terminal_service.rs | 31 ++++++++++++++++++-- 4 files changed, 48 insertions(+), 3 deletions(-) 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/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index aff85b40c84..cbf47a7e992 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -83,6 +83,13 @@ 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 && diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 8961d2dd8bf..3374b97826c 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -38,6 +38,10 @@ 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 { // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. @@ -473,6 +477,8 @@ class TerminalModel with ChangeNotifier { _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n'); _terminalOpened = false; notifyListeners(); + // Auto-close the tab/page + onClosed?.call(); } void _handleTerminalError(Map evt) { 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); + } } } From 9fdb8410d3b93dc87a16dd38f0bd22635a81938f Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 3 Jul 2026 12:49:32 +0800 Subject: [PATCH 028/121] fix: parse exit code of flutter web (#15501) * fix: parse exit code of flutter web Signed-off-by: fufesou * fix: exit-code, debug print Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/models/terminal_model.dart | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 3374b97826c..2b3fd48373b 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -1,7 +1,6 @@ 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'; @@ -251,6 +250,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); @@ -473,7 +499,7 @@ 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(); From cf1de4de629b987082bf2f920174257bbb86da71 Mon Sep 17 00:00:00 2001 From: StealUrKill <35749471+StealUrKill@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:32:47 -0500 Subject: [PATCH 029/121] Feature: Restore the last viewed monitor on auto reconnect (#15441) * Feature: Restore the last viewed monitor on auto reconnect Remembers the users last manually selected remote monitor and returns to it after an auto reconnect. In memory, reconnect only, and bounds checked against the current display count. It is skipped in "use all my displays" mode. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Address review on reconnect monitor restore Avoid a crash if the session closes during a reconnect. Don't overwrite the remembered monitor on auto restore. Defer the switch until the view is ready so a monitor with a different size renders correctly. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Guard all-displays reconnect restore against empty display list * Harden reconnect monitor restore against races and multi-UI sessions Cancel a queued restore when the user manually selects a monitor, so a newer choice is not overridden by a stale pending restore. Compare the remembered monitor against the reconnect event's display instead of the stale _pi.currentDisplay, which is intentionally left unchanged when the peer has multiple sessions. Add a frame-independent fallback so a multi-UI tab that never receives the first-image event (its display is filtered to the owning tab) still restores the remembered monitor. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Harden reconnect monitor restore: fallback timer, lifecycle, cursor Follow-up hardening on the auto-reconnect monitor restore: - Cancel the fallback timer synchronously once this tab owns the restore, so it can no longer fire while onEvent2UIRgba is awaiting canvas setup and switch displays before the canvas is ready (the offset the deferred restore exists to avoid). The multi-UI no-frame fallback stays intact. - Apply the restore in a finally so a throwing canvas init still runs it instead of stranding a queued restore with the timer already cancelled. - Cancel the fallback timer on a manual monitor switch, so a newer user selection supersedes a queued restore instead of racing it. - Restore with updateCursorPos: false, matching other programmatic display switches so an auto-restore does not reposition the cursor. --------- Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> --- flutter/lib/common.dart | 7 +++- flutter/lib/models/model.dart | 65 ++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 58ddc0cb05d..14a53b354cf 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -3350,7 +3350,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]; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 3054ffa96d1..175e3ff2da3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -112,6 +112,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 +251,8 @@ class FfiModel with ChangeNotifier { clear() { _pi = PeerInfo(); + lastUserDisplay = null; + _cancelPendingMonitorRestore(); _secure = null; _direct = null; _inputBlocked = false; @@ -932,6 +937,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 +1090,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 +1419,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; @@ -3911,17 +3948,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) { From 493b14ba78abc3dfb33f109c7f93c1c95a1dabc4 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 5 Jul 2026 23:28:32 +0800 Subject: [PATCH 030/121] Fix/session scope permission audit (#15469) * fix: enforce session-scoped permissions Restrict non-remote sessions to their allowed message types, filter out-of-scope login options, and audit rejected or filtered messages. Hide screenshot controls outside default remote sessions. Signed-off-by: fufesou * fix: typo Signed-off-by: fufesou * fix: prevent privacy mode in view-camera sessions Signed-off-by: fufesou * fix: switch display, check non-view-camera Signed-off-by: fufesou * fix: avoid sending unsupported messages Signed-off-by: fufesou * fix: session scope, add option to control close/alarm Signed-off-by: fufesou * Fix: scoped session handling for view-camera compatibility - Skip view-camera auto-login and display-management side effects - Allow harmless render broadcasts without affecting non-video sessions - Keep legacy view-camera management messages compatible as no-ops - Preserve stricter scope violations for non-video session types Signed-off-by: fufesou * update libs/hbb_common Signed-off-by: fufesou * fix: ignore repeated login request Signed-off-by: fufesou * fix: view camera, support "Take screenshot" Signed-off-by: fufesou * fix: session scoped messages, check update options Signed-off-by: fufesou * fix: session scope, check portforward before conn type voolations Signed-off-by: fufesou * fix: scoped messages, reduce changes. Signed-off-by: fufesou * fix: session scope, comments Signed-off-by: fufesou * fix: keep scoped sessions compatible with render broadcasts Allow legacy render-broadcast no-op messages for file transfer and terminal sessions while keeping port forward and mixed options scoped. Also avoid sending new render updates to non-video Flutter sessions. Signed-off-by: fufesou * fix: scope screenshot requests by video source Key screenshot requests by video source and display index so camera and monitor sessions cannot consume each other's requests. Deduplicate the Flutter render-target predicate while keeping render updates limited to video sessions. Signed-off-by: fufesou * fix: Harden scoped session message handling Filter option updates by authenticated connection type, keep legacy no-op messages compatible, and avoid noisy repeated scope violation alarms. Signed-off-by: fufesou * fix: session scope, comments Signed-off-by: fufesou * fix: Send close reason for scoped session violations Signed-off-by: fufesou * fix: Enforce scoped session message filtering - filter out-of-scope messages for limited session types - scope option updates by authenticated connection type - keep render-broadcast no-op compatibility for non-video scoped sessions - restore view-camera screenshot handling - improve session scope violation audit labels - avoid cloning option messages on the remote hot path Signed-off-by: fufesou * Fix scoped session clipboard broadcast compatibility Treat text clipboard broadcasts as no-op compatibility messages for FileTransfer and Terminal sessions, matching existing handler behavior and preventing optional scope-violation close from disconnecting those sessions. Keep ViewCamera and PortForward clipboard messages subject to normal scope enforcement. Signed-off-by: fufesou * fix: log warn Signed-off-by: fufesou * fix: restrict Flutter clipboard sync to default sessions Signed-off-by: fufesou * fix: session scope, comments and tests Signed-off-by: fufesou * fix: session scope, reset sessions in login handle Signed-off-by: fufesou * fix: session scope, view camera, allow clipboard noop Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/toolbar.dart | 2 +- libs/hbb_common | 2 +- src/client/io_loop.rs | 6 + src/flutter.rs | 7 +- src/flutter_ffi.rs | 8 + src/server/connection.rs | 818 +++++++++++++++++++++++- src/server/video_service.rs | 16 +- src/ui_session_interface.rs | 10 +- 8 files changed, 837 insertions(+), 32 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 9653b547823..83638000b3d 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -606,7 +606,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/libs/hbb_common b/libs/hbb_common index a920d00945e..7e1c392c62d 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 +Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 68cd6970046..a97b6ea7017 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1090,6 +1090,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 +1114,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") diff --git a/src/flutter.rs b/src/flutter.rs index 73f2dbde325..e6b325cbeb6 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1437,7 +1437,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 +1447,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) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9595ddd3160..d83cc37202c 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1224,9 +1224,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 +1239,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(); } } diff --git a/src/server/connection.rs b/src/server/connection.rs index a3e0ccafab8..40a1606ef32 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -240,6 +240,18 @@ 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 { @@ -384,6 +396,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 @@ -571,6 +585,7 @@ 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, @@ -1501,6 +1516,23 @@ impl Connection { }); } + 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, + }), + ); + } + #[inline] async fn post_audit_async(url: String, v: Value) -> ResultType { crate::post_request(url, v.to_string(), "").await @@ -1897,10 +1929,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 { @@ -2407,6 +2438,14 @@ 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; + } + 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); @@ -2474,12 +2513,21 @@ 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 { self.handle_login_request_without_validation(&lr).await; if self.authorized { return true; } + self.reset_session_scope_for_login(); match lr.union { Some(login_request::Union::FileTransfer(ft)) => { if !Self::permission( @@ -2989,7 +3037,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. @@ -3017,7 +3065,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")] @@ -3404,10 +3452,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 }); @@ -3415,19 +3467,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( @@ -3495,10 +3555,16 @@ impl Connection { } } #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::ChangeResolution(r)) => self.change_resolution(None, &r), + Some(misc::Union::ChangeResolution(r)) => { + if !self.view_camera { + 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) + if !self.view_camera { + self.change_resolution(Some(dr.display as _), &dr.resolution); + } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -3584,6 +3650,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, @@ -4080,7 +4147,7 @@ impl Connection { self.switch_display_to(display_idx, server.clone()); #[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 { @@ -5204,6 +5271,398 @@ 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.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(); @@ -5599,6 +6058,7 @@ pub enum AlarmAuditType { ExceedIPv6PrefixAttempts = 6, TerminalOsLoginBackoff = 7, TerminalOsLoginConcurrency = 8, + SessionScopeViolation = 9, } pub enum FileAuditType { @@ -6216,6 +6676,7 @@ mod raii { } } +#[cfg(test)] mod test { #[allow(unused)] use super::*; @@ -6258,4 +6719,325 @@ 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"), + ), + (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_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"), + ), + ], + ), + ( + AuthConnType::Remote, + vec![ + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + None, + ), + (msg(|m| m.set_terminal_action(TerminalAction::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_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/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/ui_session_interface.rs b/src/ui_session_interface.rs index 1e35672ec8e..59f81562dd9 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1809,10 +1809,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( From 37141afece2b2dd9d7c2f3238373d90b8e748e7c Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 6 Jul 2026 16:17:32 +0800 Subject: [PATCH 031/121] refact: remove feature cli (#15524) Signed-off-by: fufesou --- Cargo.lock | 25 +---- Cargo.toml | 3 - src/cli.rs | 199 --------------------------------------- src/client/file_trait.rs | 6 -- src/common.rs | 5 +- src/flutter_ffi.rs | 2 +- src/keyboard.rs | 8 +- src/lib.rs | 5 +- src/main.rs | 71 -------------- src/ui.rs | 6 +- src/ui_interface.rs | 1 - 11 files changed, 12 insertions(+), 319 deletions(-) delete mode 100644 src/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 8ec2d4a5327..0354e244892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6588,7 +6588,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", @@ -7152,17 +7152,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 +7163,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" @@ -7283,7 +7262,6 @@ dependencies = [ "cfg-if 1.0.0", "chrono", "cidr-utils", - "clap 4.5.53", "clipboard", "clipboard-master", "cocoa 0.24.1", @@ -7341,7 +7319,6 @@ dependencies = [ "repng", "reqwest", "ringbuf", - "rpassword 7.3.1", "rubato", "runas", "rust-pulsectl", diff --git a/Cargo.toml b/Cargo.toml index c320401ad8c..36ad2200da3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ path = "src/service.rs" [features] inline = [] -cli = [] use_samplerate = ["samplerate"] use_rubato = ["rubato"] use_dasp = ["dasp"] @@ -62,8 +61,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" 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/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/common.rs b/src/common.rs index 69e3ec3045d..5aed8c1a4e7 100644 --- a/src/common.rs +++ b/src/common.rs @@ -764,15 +764,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(); diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index d83cc37202c..315df5e5b90 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1026,7 +1026,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()); 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/lib.rs b/src/lib.rs index 5621d5e2a68..49cb2b7e97c 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,11 +37,9 @@ 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; diff --git a/src/main.rs b/src/main.rs index 8d061bff89a..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 Tech Pte. 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/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_interface.rs b/src/ui_interface.rs index e01595ef769..1a892784072 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(); From 28930c04635ffbc487175b2b0d62e64fd40ba892 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 6 Jul 2026 17:05:11 +0800 Subject: [PATCH 032/121] fix: non-E2EE show dialog (#15514) * fix: non-E2EE show dialog Signed-off-by: fufesou * fix: build web, bridge Signed-off-by: fufesou * fix: direct IP access, do not snow non-E2EE dialog Signed-off-by: fufesou * fix: non E2EE dialog, update contents Signed-off-by: fufesou * fix: non-E2EE, show dialog, port forward Signed-off-by: fufesou * fix: non-E2EE dialog, port forward, ignore direct IP access Signed-off-by: fufesou * fix: non-E2EE is_direct_ip_access() Signed-off-by: fufesou * Simple refactor Signed-off-by: fufesou * fix: non-E2EE dialog, port forward, close socket on disconnect Signed-off-by: fufesou * fix: non-E2EE dialog, incorrect reuse of Data::Close Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common.dart | 42 +++++++++++++++++++++++++++++++++++++ flutter/lib/web/bridge.dart | 9 ++++++++ src/client.rs | 23 ++++++++++++++++++++ src/client/io_loop.rs | 21 +++++++++++++++++-- src/common.rs | 4 ++++ src/flutter_ffi.rs | 10 +++++++++ src/lang/ar.rs | 2 ++ src/lang/be.rs | 2 ++ src/lang/bg.rs | 2 ++ src/lang/ca.rs | 2 ++ src/lang/cn.rs | 2 ++ src/lang/cs.rs | 2 ++ src/lang/da.rs | 2 ++ src/lang/de.rs | 2 ++ src/lang/el.rs | 2 ++ src/lang/en.rs | 1 + src/lang/eo.rs | 2 ++ src/lang/es.rs | 2 ++ src/lang/et.rs | 2 ++ src/lang/eu.rs | 2 ++ src/lang/fa.rs | 2 ++ src/lang/fi.rs | 2 ++ src/lang/fr.rs | 2 ++ src/lang/ge.rs | 2 ++ src/lang/gu.rs | 2 ++ src/lang/he.rs | 2 ++ src/lang/hi.rs | 2 ++ src/lang/hr.rs | 2 ++ src/lang/hu.rs | 2 ++ src/lang/id.rs | 2 ++ src/lang/it.rs | 2 ++ src/lang/ja.rs | 2 ++ src/lang/ko.rs | 2 ++ src/lang/kz.rs | 2 ++ src/lang/lt.rs | 2 ++ src/lang/lv.rs | 2 ++ src/lang/ml.rs | 2 ++ src/lang/nb.rs | 2 ++ src/lang/nl.rs | 2 ++ src/lang/pl.rs | 2 ++ src/lang/pt_PT.rs | 2 ++ src/lang/ptbr.rs | 2 ++ src/lang/ro.rs | 2 ++ src/lang/ru.rs | 2 ++ src/lang/sc.rs | 2 ++ src/lang/sk.rs | 2 ++ src/lang/sl.rs | 2 ++ src/lang/sq.rs | 2 ++ src/lang/sr.rs | 2 ++ src/lang/sv.rs | 2 ++ src/lang/ta.rs | 2 ++ src/lang/template.rs | 2 ++ src/lang/th.rs | 2 ++ src/lang/tr.rs | 2 ++ src/lang/tw.rs | 2 ++ src/lang/uk.rs | 2 ++ src/lang/vi.rs | 2 ++ src/port_forward.rs | 13 +++++++++++- src/server/connection.rs | 4 +--- src/ui/common.tis | 11 +++++++++- src/ui/msgbox.tis | 16 +++++++++++--- src/ui/remote.rs | 1 + src/ui_session_interface.rs | 9 ++++++++ 63 files changed, 254 insertions(+), 10 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 14a53b354cf..1651f670189 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() { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 2df0b3426a3..ac48dfb0ffd 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1914,6 +1914,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/src/client.rs b/src/client.rs index 680ed1bec95..dcd5941dfbe 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3792,6 +3792,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 +3816,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 { diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index a97b6ea7017..c0eb7fb57fd 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 diff --git a/src/common.rs b/src/common.rs index 5aed8c1a4e7..b875d548cee 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2619,6 +2619,10 @@ 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) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 315df5e5b90..0777443cf3b 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -3028,6 +3028,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, diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2b7e8d6873d..b73147b3562 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "اتصال الوسيط"), ("Secure Connection", "اتصال آمن"), ("Insecure Connection", "اتصال غير آمن"), + ("Continue", ""), ("Scale original", "المقياس الأصلي"), ("Scale adaptive", "مقياس التكيف"), ("General", "عام"), @@ -763,5 +764,6 @@ 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هل تريد المتابعة على أي حال؟"), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index d417a448ee8..5c61fe4f599 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Рэтрансляванае падключэнне"), ("Secure Connection", "Бяспечнае падключэнне"), ("Insecure Connection", "Нябяспечнае падключэнне"), + ("Continue", ""), ("Scale original", "Арыгінальны маштаб"), ("Scale adaptive", "Адаптыўны маштаб"), ("General", "Агульныя"), @@ -763,5 +764,6 @@ 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Усё роўна працягнуць?"), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 0f729342694..30d01b381ce 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Релейна връзка"), ("Secure Connection", "Сигурна връзка"), ("Insecure Connection", "Несигурна връзка"), + ("Continue", ""), ("Scale original", "Оригинален мащаб"), ("Scale adaptive", "Приспособимо мащабиране"), ("General", "Основен"), @@ -763,5 +764,6 @@ 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Да се продължи ли въпреки това?"), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 4cabf259b78..1d02225b8c4 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connexió amb repetidor"), ("Secure Connection", "Connexió segura"), ("Insecure Connection", "Connexió no segura"), + ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptativa"), ("General", "General"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index bc12b3ed103..aac57d011e5 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中继连接"), ("Secure Connection", "安全连接"), ("Insecure Connection", "非安全连接"), + ("Continue", ""), ("Scale original", "原始尺寸"), ("Scale adaptive", "适应窗口"), ("General", "常规"), @@ -763,5 +764,6 @@ 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仍要继续吗?"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index ec789fdf761..91cf8a6c174 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Připojení předávací server"), ("Secure Connection", "Zabezpečené připojení"), ("Insecure Connection", "Nezabezpečené připojení"), + ("Continue", ""), ("Scale original", "Originální měřítko"), ("Scale adaptive", "Adaptivní měřítko"), ("General", "Obecné"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 8c2e32193cc..ab057404a6e 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Viderestillingsforbindelse"), ("Secure Connection", "Sikker forbindelse"), ("Insecure Connection", "Usikker forbindelse"), + ("Continue", ""), ("Scale original", "Original skalering"), ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 451bf1af735..8536ebf4264 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay-Verbindung"), ("Secure Connection", "Sichere Verbindung"), ("Insecure Connection", "Unsichere Verbindung"), + ("Continue", ""), ("Scale original", "Keine Skalierung"), ("Scale adaptive", "Anpassbare Skalierung"), ("General", "Allgemein"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index b1b6ff9229c..16865bcf6c7 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Αναμεταδιδόμενη σύνδεση"), ("Secure Connection", "Ασφαλής σύνδεση"), ("Insecure Connection", "Μη ασφαλής σύνδεση"), + ("Continue", ""), ("Scale original", "Κλιμάκωση πρωτότυπου"), ("Scale adaptive", "Προσαρμοσμένη κλίμακα"), ("General", "Γενικά"), @@ -763,5 +764,6 @@ 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Συνέχεια παρ' όλα αυτά;"), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index d68361255a9..171a5dd44d0 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -279,5 +279,6 @@ 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?") ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 31890084254..28ab2f16501 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relajsa Konekto"), ("Secure Connection", "Sekura Konekto"), ("Insecure Connection", "Nesekura Konekto"), + ("Continue", ""), ("Scale original", "Skalo originalo"), ("Scale adaptive", "Skalo adapta"), ("General", "Ĝenerala"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index f820994d713..1271e49226f 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexión Relay"), ("Secure Connection", "Conexión segura"), ("Insecure Connection", "Conexión insegura"), + ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptativa"), ("General", "General"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 2dbf0f723a2..39ec3cce47e 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Releeühendus"), ("Secure Connection", "Turvaline ühendus"), ("Insecure Connection", "Ebaturvaline ühendus"), + ("Continue", ""), ("Scale original", "Originaalskaala"), ("Scale adaptive", "Kohanduv skaala"), ("General", "Üldine"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index d6211df6f8e..bfc497253d0 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Konexio igorria"), ("Secure Connection", "Konexio segurua"), ("Insecure Connection", "Konexio ez-segurua"), + ("Continue", ""), ("Scale original", "Jatorrizko eskala"), ("Scale adaptive", "Eskala moldagarria"), ("General", "Orokorra"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 2e02502e5a0..3b46099f049 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay ارتباط"), ("Secure Connection", "ارتباط امن"), ("Insecure Connection", "ارتباط غیر امن"), + ("Continue", ""), ("Scale original", "مقیاس اصلی"), ("Scale adaptive", "مقیاس تطبیقی"), ("General", "عمومی"), @@ -763,5 +764,6 @@ 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با این حال ادامه می‌دهید؟"), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index c3e42d21417..91a2f714d06 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Välitetty yhteys"), ("Secure Connection", "Suojattu yhteys"), ("Insecure Connection", "Suojaamaton yhteys"), + ("Continue", ""), ("Scale original", "Skaalaa alkuperäinen"), ("Scale adaptive", "Mukautuva skaalaus"), ("General", "Yleiset"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 2ddb4e84da7..595c0efb558 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connexion via relais"), ("Secure Connection", "Connexion sécurisée"), ("Insecure Connection", "Connexion non sécurisée"), + ("Continue", ""), ("Scale original", "Échelle originale"), ("Scale adaptive", "Échelle adaptative"), ("General", "Général"), @@ -763,5 +764,6 @@ 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 ?"), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 52909eb351f..d3c0c0b608f 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "რეტრანსლირებული კავშირი"), ("Secure Connection", "უსაფრთხო კავშირი"), ("Insecure Connection", "არაუსაფრთხო კავშირი"), + ("Continue", ""), ("Scale original", "ორიგინალური მასშტაბი"), ("Scale adaptive", "ადაპტირებადი მასშტაბი"), ("General", "ზოგადი"), @@ -763,5 +764,6 @@ 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მაინც გააგრძელებთ?"), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 1c1d89cae64..9922654360a 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "રિલે કનેક્શન"), ("Secure Connection", "સુરક્ષિત કનેક્શન"), ("Insecure Connection", "અસુરક્ષિત કનેક્શન"), + ("Continue", ""), ("Scale original", "મૂળ સ્કેલ"), ("Scale adaptive", "એડેપ્ટિવ સ્કેલ"), ("General", "સામાન્ય"), @@ -763,5 +764,6 @@ 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શું તેમ છતાં ચાલુ રાખવું?"), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 5317804612a..19cab0e7153 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "חיבור באמצעות ממסר"), ("Secure Connection", "חיבור מאובטח"), ("Insecure Connection", "חיבור לא מאובטח"), + ("Continue", ""), ("Scale original", "קנה מידה מקורי"), ("Scale adaptive", "קנה מידה מותאם"), ("General", "כללי"), @@ -763,5 +764,6 @@ 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להמשיך בכל זאת?"), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 98b259d9f13..4c2c111098c 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "रिले कनेक्शन"), ("Secure Connection", "सुरक्षित कनेक्शन"), ("Insecure Connection", "असुरक्षित कनेक्शन"), + ("Continue", ""), ("Scale original", "मूल पैमाना"), ("Scale adaptive", "अनुकूली पैमाना"), ("General", "सामान्य"), @@ -763,5 +764,6 @@ 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फिर भी जारी रखें?"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 59909030a16..2323d37d207 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredna veza"), ("Secure Connection", "Sigurna veza"), ("Insecure Connection", "Nesigurna veza"), + ("Continue", ""), ("Scale original", "Skaliraj izvornik"), ("Scale adaptive", "Prilagođeno skaliranje"), ("General", "Općenito"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 31ba1ce05c1..11c6db083a5 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Kapcsolódás továbbító-kiszolgálón keresztül"), ("Secure Connection", "Biztonságos kapcsolat"), ("Insecure Connection", "Nem biztonságos kapcsolat"), + ("Continue", ""), ("Scale original", "Eredeti méretarány"), ("Scale adaptive", "Adaptív méretarány"), ("General", "Általános"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index c9d2530e231..2dfb30b9be1 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Koneksi Relay"), ("Secure Connection", "Koneksi aman"), ("Insecure Connection", "Koneksi Tidak Aman"), + ("Continue", ""), ("Scale original", "Skala asli"), ("Scale adaptive", "Skala adaptif"), ("General", "Umum"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index f5f9c88f505..c93ab8019aa 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connessione relay"), ("Secure Connection", "Connessione sicura"), ("Insecure Connection", "Connessione non sicura"), + ("Continue", ""), ("Scale original", "Scala originale"), ("Scale adaptive", "Scala adattiva"), ("General", "Generale"), @@ -763,5 +764,6 @@ 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.\nContinuare comunque?"), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 187fb2b5167..4385f9b2672 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中継接続"), ("Secure Connection", "安全な接続"), ("Insecure Connection", "安全でない接続"), + ("Continue", ""), ("Scale original", "オリジナルのサイズ"), ("Scale adaptive", "ウィンドウに合わせる"), ("General", "一般"), @@ -763,5 +764,6 @@ 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それでも続行しますか?"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 60560cb02e4..921406ed5ff 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "릴레이 연결"), ("Secure Connection", "보안 연결"), ("Insecure Connection", "보안되지 않은 연결"), + ("Continue", ""), ("Scale original", "원본 크기 조정"), ("Scale adaptive", "크기 조정 가능"), ("General", "일반"), @@ -763,5 +764,6 @@ 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그래도 계속하시겠습니까?"), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 0f1dfa9baba..1c47ebb6243 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Релай Қосылым"), ("Secure Connection", "Қауіпсіз Қосылым"), ("Insecure Connection", "Қатерлі Қосылым"), + ("Continue", ""), ("Scale original", "Scale original"), ("Scale adaptive", "Scale adaptive"), ("General", "Жалпы"), @@ -763,5 +764,6 @@ 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Сонда да жалғастыру керек пе?"), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 8065ab00f0a..a0e9dab3891 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Tarpinė jungtis"), ("Secure Connection", "Saugus ryšys"), ("Insecure Connection", "Nesaugus ryšys"), + ("Continue", ""), ("Scale original", "Pakeisti originalų mastelį"), ("Scale adaptive", "Pritaikomas mastelis"), ("General", "Bendra"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 23756b2c88c..308e2fb1368 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Releja savienojums"), ("Secure Connection", "Drošs savienojums"), ("Insecure Connection", "Nedrošs savienojums"), + ("Continue", ""), ("Scale original", "Mērogs oriģināls"), ("Scale adaptive", "Mērogs adaptīvs"), ("General", "Vispārīgi"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index bce3fc9c5b2..8c69c7b442a 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "റിലേ കണക്ഷൻ"), ("Secure Connection", "സുരക്ഷിതമായ കണക്ഷൻ"), ("Insecure Connection", "സുരക്ഷിതമല്ലാത്ത കണക്ഷൻ"), + ("Continue", ""), ("Scale original", "ഒറിജിനൽ വലിപ്പം"), ("Scale adaptive", "അഡാപ്റ്റീവ് വലിപ്പം"), ("General", "പൊതുവായവ"), @@ -763,5 +764,6 @@ 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എങ്കിലും തുടരണമോ?"), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 072d1ba2f69..ab6eefe4931 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Viderekoblet tilkobling"), ("Secure Connection", "Sikker tilkobling"), ("Insecure Connection", "Usikker tilkobling"), + ("Continue", ""), ("Scale original", "Original skalering"), ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index ff4abd2f2eb..21288a9d8a2 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relaisverbinding"), ("Secure Connection", "Beveiligde Verbinding"), ("Insecure Connection", "Onveilige Verbinding"), + ("Continue", ""), ("Scale original", "Oorspronkelijk formaat"), ("Scale adaptive", "Automatisch schalen"), ("General", "Algemeen"), @@ -763,5 +764,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "Weergeven op de geminimaliseerde werkbalk"), ("All monitors", "Alle monitoren"), ("#{} monitor", "Monitor {}"), + ("conn-e2ee-unavailable-tip", "End-to-endversleuteling kon niet worden geverifieerd.\nHet externe apparaat wordt mogelijk nog ingesteld. Probeer het later opnieuw.\nAls dit blijft gebeuren, is de server mogelijk niet vertrouwd.\nToch doorgaan?"), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 26c51bbb82a..9de6cfd9217 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Połączenie przez bramkę"), ("Secure Connection", "Połączenie szyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"), + ("Continue", ""), ("Scale original", "Skalowanie oryginalne"), ("Scale adaptive", "Dopasuj do wyświetlacza"), ("General", "Ogólne"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index b5f117be83e..fabe3742458 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexão de relé"), ("Secure Connection", "Conexão segura"), ("Insecure Connection", "Conexão insegura"), + ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptável"), ("General", "Geral"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 5e93d2cc89f..1b0e90a472c 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexão via Relay"), ("Secure Connection", "Conexão Segura"), ("Insecure Connection", "Conexão Insegura"), + ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptada"), ("General", "Geral"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 41d2e42c500..a828db9c85c 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexiune prin retransmisie"), ("Secure Connection", "Conexiune securizată"), ("Insecure Connection", "Conexiune nesecurizată"), + ("Continue", ""), ("Scale original", "Dimensiune originală"), ("Scale adaptive", "Scalare automată"), ("General", "General"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 2f80a8d447e..1fa53a6a4bc 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Ретранслируемое подключение"), ("Secure Connection", "Безопасное подключение"), ("Insecure Connection", "Небезопасное подключение"), + ("Continue", ""), ("Scale original", "Оригинальный масштаб"), ("Scale adaptive", "Адаптивный масштаб"), ("General", "Общие"), @@ -763,5 +764,6 @@ 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Все равно продолжить?"), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1bf065fe703..43829f69b17 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connessione tramudada (relay)"), ("Secure Connection", "Connessione segura"), ("Insecure Connection", "Connessione non segura"), + ("Continue", ""), ("Scale original", "Iscala originale"), ("Scale adaptive", "Iscala adativa"), ("General", "Generale"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 86b1367bdab..d2700a52e02 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Reléové pripojenie"), ("Secure Connection", "Zabezpečené pripojenie"), ("Insecure Connection", "Nezabezpečené pripojenie"), + ("Continue", ""), ("Scale original", "Pôvodná mierka"), ("Scale adaptive", "Prispôsobivá mierka"), ("General", "Všeobecné"), @@ -763,5 +764,6 @@ 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ť?"), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 8f0f50f4d65..a9e4b6b1383 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredovana povezava"), ("Secure Connection", "Zavarovana povezava"), ("Insecure Connection", "Nezavarovana povezava"), + ("Continue", ""), ("Scale original", "Originalna velikost"), ("Scale adaptive", "Prilagojena velikost"), ("General", "Splošno"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 166ba61e736..f8b30bbc320 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Lidhja rele"), ("Secure Connection", "Lidhje e sigurt"), ("Insecure Connection", "Lidhje e pasigurt"), + ("Continue", ""), ("Scale original", "Shkalla origjinale"), ("Scale adaptive", " E përsjhtatshme në shkallë"), ("General", "Gjeneral"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 56db4308dd8..ecbaed7acd9 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredna konekcija"), ("Secure Connection", "Bezbedna konekcija"), ("Insecure Connection", "Nebezbedna konekcija"), + ("Continue", ""), ("Scale original", "Skaliraj original"), ("Scale adaptive", "Adaptivno skaliranje"), ("General", "Uopšteno"), @@ -763,5 +764,6 @@ 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Наставити свеједно?"), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 4b7cacd80e8..42d9c5f1ec5 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relayanslutning"), ("Secure Connection", "Säker anslutning"), ("Insecure Connection", "Osäker anslutning"), + ("Continue", ""), ("Scale original", "Skala orginal"), ("Scale adaptive", "Skala adaptivt"), ("General", "Generellt"), @@ -763,5 +764,6 @@ 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å?"), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index ba2a1dcb68f..16a5cd1a170 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "ரிலே இணைப்பு"), ("Secure Connection", "பாதுகாப்பான இணைப்பு"), ("Insecure Connection", "பாதுகாப்பற்ற இணைப்பு"), + ("Continue", ""), ("Scale original", "அசல் அளவு"), ("Scale adaptive", "தகவமைப்பு அளவு"), ("General", "பொது"), @@ -763,5 +764,6 @@ 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எப்படியும் தொடரவா?"), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 2a80bbc4dd1..faabf087605 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", ""), ("Secure Connection", ""), ("Insecure Connection", ""), + ("Continue", ""), ("Scale original", ""), ("Scale adaptive", ""), ("General", ""), @@ -763,5 +764,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", ""), ("All monitors", ""), ("#{} monitor", ""), + ("conn-e2ee-unavailable-tip", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 251e4d65bd0..a905ec863d6 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "การเชื่อมต่อแบบ Relay "), ("Secure Connection", "การเชื่อมต่อที่ปลอดภัย"), ("Insecure Connection", "การเชื่อมต่อที่ไม่ปลอดภัย"), + ("Continue", ""), ("Scale original", "ขนาดเดิม"), ("Scale adaptive", "ขนาดยืดหยุ่น"), ("General", "ทั่วไป"), @@ -763,5 +764,6 @@ 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ต้องการดำเนินการต่อหรือไม่?"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 831ae76f0ce..58875d61929 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Aktarmalı Bağlantı"), ("Secure Connection", "Güvenli Bağlantı"), ("Insecure Connection", "Güvenli Olmayan Bağlantı"), + ("Continue", ""), ("Scale original", "Orijinal ölçekte"), ("Scale adaptive", "Uyarlanabilir ölçekte"), ("General", "Genel"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 31a2e4f6ae7..2771c21ce55 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中繼連線"), ("Secure Connection", "安全連線"), ("Insecure Connection", "非安全連線"), + ("Continue", ""), ("Scale original", "原始尺寸"), ("Scale adaptive", "適應視窗"), ("General", "一般"), @@ -763,5 +764,6 @@ 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仍要繼續嗎?"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index e1d15ecd0ad..5996eedfbd9 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Ретрансльоване підключення"), ("Secure Connection", "Безпечне підключення"), ("Insecure Connection", "Небезпечне підключення"), + ("Continue", ""), ("Scale original", "Оригінальний масштаб"), ("Scale adaptive", "Адаптивний масштаб"), ("General", "Загальні"), @@ -763,5 +764,6 @@ 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Продовжити все одно?"), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index ea5f219a795..48b567da732 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -332,6 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Kết nối chuyển tiếp"), ("Secure Connection", "Kết nối bảo mật"), ("Insecure Connection", "Kết nối không bảo mật"), + ("Continue", ""), ("Scale original", "Tỷ lệ gốc"), ("Scale adaptive", "Tỷ lệ thích ứng"), ("General", "Chung"), @@ -763,5 +764,6 @@ 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?"), ].iter().cloned().collect(); } diff --git a/src/port_forward.rs b/src/port_forward.rs index 9c013095126..8b190fb1e37 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; diff --git a/src/server/connection.rs b/src/server/connection.rs index 40a1606ef32..bdf9cba8b18 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2587,9 +2587,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; 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/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..1d5ceb139d2 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -513,6 +513,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); diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 59f81562dd9..bf2e04c6ba7 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1408,6 +1408,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(); From 6c578292e8ebbbec708b76986ba8c4bc7c509747 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 6 Jul 2026 18:00:39 +0800 Subject: [PATCH 033/121] bump to 1.4.9 --- .github/workflows/flutter-build.yml | 2 +- .github/workflows/playground.yml | 2 +- Cargo.lock | 4 ++-- Cargo.toml | 2 +- appimage/AppImageBuilder-aarch64.yml | 2 +- appimage/AppImageBuilder-x86_64.yml | 2 +- flutter/pubspec.yaml | 2 +- libs/portable/Cargo.toml | 2 +- res/PKGBUILD | 2 +- res/rpm-flutter-suse.spec | 2 +- res/rpm-flutter.spec | 2 +- res/rpm.spec | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index f4c9380edca..2491789e6a4 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -44,7 +44,7 @@ env: # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version - VERSION: "1.4.8" + VERSION: "1.4.9" NDK_VERSION: "r28c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index dcf2483594b..41b9c0c139f 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -17,7 +17,7 @@ env: TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - VERSION: "1.4.8" + VERSION: "1.4.9" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/Cargo.lock b/Cargo.lock index 0354e244892..57177174db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7249,7 +7249,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.8" +version = "1.4.9" dependencies = [ "android-wakelock", "android_logger", @@ -7362,7 +7362,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.8" +version = "1.4.9" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index 36ad2200da3..d909ec3a208 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" 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/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/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index e3667879048..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" 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/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 From e2149974ccda5a063a2647bdd10d70da850b629b Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:54:22 +0800 Subject: [PATCH 034/121] harden wf_cliprdr.c (#15515) * harden wf_cliprdr.c * fix copilot review * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix review * fix review * condense hardening comments, fix style in wf_cliprdr.c Comment-only cleanup of the review-justification comments; also move the mutex wait result declaration to the top of the block and fix continuation-line indentation. No behavior change. Co-Authored-By: Claude Fable 5 * add invariant tests for file contents request/response hardening Cover the zeroed optional request fields, stream ID filtering, oversized/NULL response rejection and the zero-byte EOF path. Co-Authored-By: Claude Fable 5 * address copilot review findings in wf_cliprdr.c - Reject a negative FILECONTENTS_SIZE result: m_lSize is unsigned, so a negative value became a huge bogus stream size that keeps reads going. - Use a unique per-stream counter as the CLIPRDR streamId instead of a truncated IStream pointer, which could collide or be reused after free (and leaked heap addresses to the peer). - Add req_f_request_mutex to serialize whole file-contents request/response cycles, enforcing the previously assumed one-outstanding-request invariant when multiple streams are read concurrently. Bounded acquire so a wedged request fails the read instead of hanging a consumer. Co-Authored-By: Claude Fable 5 * serialize file-contents request state and poison streams after timeout - Extract lock_mutex() for the WAIT_OBJECT_0/WAIT_ABANDONED idiom shared by take_req_fdata, the request-serialization acquire, and the response handler. - Collapse the acquire/send/take/release cycle into cliprdr_request_filecontents_sync(), used by CliprdrStream_Read and the size probe in CliprdrStream_New. - Publish req_f_stream_id_expected/req_f_size_requested under req_f_mutex in the sender and read them under the same lock in the response handler, removing the cross-thread data race on those fields. - Poison a stream (m_failed) after a request fails/times out, so a late response carrying a previous offset's bytes cannot satisfy a later same-stream read. Co-Authored-By: Claude Fable 5 * key the responder stream cache on connID as well as streamId Per-stream ids restart from 1 in each peer process, so two connections can emit the same streamId. The process-static pStreamStc cache keyed only on streamId could then serve one peer the IStream cached for another peer (a different file), silently returning wrong-file bytes. Add connID to the key. Co-Authored-By: Claude Fable 5 * harden the format-data path against late/duplicate responses The format-data rendezvous had the same single-slot race the file-contents path just fixed: the channel thread rewrote clipboard->hmem with no lock while explorer-thread consumers read/freed it, nothing serialized concurrent requests, and no flag told an expected response from a stray one. - Add format_request_mutex (serializes the whole request/response cycle) and hmem_mutex (guards the hmem hand-off and formatDataRespExpected). - cliprdr_send_data_request now takes ownership of the response buffer under hmem_mutex and returns it to the caller, so a later response cannot touch a buffer a consumer is using. All three consumers (GetData, WM_RENDERFORMAT, DELAYED_RENDERING) and the WM_CLIPBOARDUPDATE cleanup use the returned/taken handle instead of the shared slot. - The response handler drops any response arriving while formatDataRespExpected is clear (late/duplicate/unsolicited), consumes the flag on the first response, and no longer dereferences a NULL clipboard in the SetEvent path. Pre-existing issue, not introduced by this branch; generalizes the file-contents hardening to the format-data path. Co-Authored-By: Claude Fable 5 * remove the dedicated wf-cliprdr CI workflow Drop .github/workflows/wf-cliprdr-ci.yml on this branch as requested. Co-Authored-By: Claude Fable 5 * remove wf-cliprdr invariant tests Drop tests/test_invariant_wf_cliprdr.c on this branch as requested. Co-Authored-By: Claude Fable 5 * refactor and simplify, remove mutex which is dangeours * fix copilot false report * fix review * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .github/workflows/wf-cliprdr-ci.yml | 85 ----------------------- libs/clipboard/src/windows/wf_cliprdr.c | 68 ++++++++++++++---- tests/test_invariant_wf_cliprdr.c | 92 ------------------------- 3 files changed, 54 insertions(+), 191 deletions(-) delete mode 100644 .github/workflows/wf-cliprdr-ci.yml delete mode 100644 tests/test_invariant_wf_cliprdr.c diff --git a/.github/workflows/wf-cliprdr-ci.yml b/.github/workflows/wf-cliprdr-ci.yml deleted file mode 100644 index bc65d22e866..00000000000 --- a/.github/workflows/wf-cliprdr-ci.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: wf-cliprdr CI - -on: - workflow_dispatch: - pull_request: - paths: - - "libs/clipboard/src/windows/**" - - "tests/test_invariant_wf_cliprdr.c" - - ".github/workflows/wf-cliprdr-ci.yml" - push: - branches: - - master - paths: - - "libs/clipboard/src/windows/**" - - "tests/test_invariant_wf_cliprdr.c" - - ".github/workflows/wf-cliprdr-ci.yml" - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: wf_cliprdr invariant test - runs-on: windows-2022 - - steps: - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Set up MSVC - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 - with: - arch: x64 - - - name: Setup vcpkg with GitHub Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: C:\vcpkg - doNotCache: false - - - name: Install vcpkg dependency - shell: pwsh - run: | - & "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed" - - - name: Build test - shell: pwsh - run: | - $testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr' - New-Item -ItemType Directory -Force $testRoot | Out-Null - - $testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c') - $cmakeLists = @( - 'cmake_minimum_required(VERSION 3.20)' - 'project(test_invariant_wf_cliprdr C)' - '' - 'set(CMAKE_C_STANDARD 11)' - 'set(CMAKE_C_STANDARD_REQUIRED ON)' - 'set(CMAKE_C_EXTENSIONS OFF)' - '' - 'find_package(check CONFIG REQUIRED)' - '' - 'add_executable(test_invariant_wf_cliprdr' - ' "TEST_SOURCE"' - ')' - '' - 'target_link_libraries(test_invariant_wf_cliprdr PRIVATE' - ' $<$:Check::check>' - ' $<$>:Check::checkShared>' - ')' - ) -join [Environment]::NewLine - $cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt') - - cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows - cmake --build (Join-Path $testRoot 'out') --config Release - - - name: Run test - shell: pwsh - run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index 78968cee233..b535b2ec7e5 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -232,6 +232,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; @@ -285,6 +286,9 @@ 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 + LONG req_f_stream_id_seq; // source of unique per-stream ids size_t nFiles; size_t file_array_size; @@ -315,7 +319,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); @@ -398,7 +402,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; @@ -411,12 +415,23 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO 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) { @@ -628,6 +643,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) { @@ -638,16 +654,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; } @@ -1773,12 +1801,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; @@ -1789,12 +1817,11 @@ 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; 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; @@ -3015,6 +3042,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; @@ -3088,7 +3116,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; @@ -3119,6 +3148,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, { pStreamStc = vStgMedium.pstm; uStreamIdStc = fileContentsRequest->streamId; + uConnIdStc = fileContentsRequest->connID; bIsStreamFile = TRUE; } @@ -3282,6 +3312,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; @@ -3292,6 +3325,13 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, } 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) { 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; -} From 005a8b4a04fd906c707eefd69c4898aa2c696202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?VenusGirl=E2=9D=A4?= Date: Wed, 8 Jul 2026 12:09:39 +0900 Subject: [PATCH 035/121] Update Korean (#15525) Updated Korean translations for clarity and accuracy. --- src/lang/ko.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 921406ed5ff..386d7dc05d9 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", "빌드 날짜"), @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "릴레이 연결"), ("Secure Connection", "보안 연결"), ("Insecure Connection", "보안되지 않은 연결"), - ("Continue", ""), ("Scale original", "원본 크기 조정"), ("Scale adaptive", "크기 조정 가능"), ("General", "일반"), @@ -764,6 +763,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show on the minimized toolbar", "최소화된 도구 모음에 표시"), ("All monitors", "모든 모니터"), ("#{} monitor", "#{} 모니터"), - ("conn-e2ee-unavailable-tip", "종단 간 암호화를 확인할 수 없습니다.\n원격 장치가 아직 설정 중일 수 있습니다. 나중에 다시 시도하세요.\n이 문제가 계속되면 서버를 신뢰할 수 없을 수 있습니다.\n그래도 계속하시겠습니까?"), + ("conn-e2ee-unavailable-tip", "종단 간 암호화를 확인할 수 없습니다.\n원격 장치가 여전히 설정 중일 수 있습니다. 나중에 다시 시도해 보세요.\n이런 일이 계속 발생하면 서버가 신뢰할 수 없을 수도 있습니다.\n어쨌든 계속하시겠습니까?"), ].iter().cloned().collect(); } From acb9f63e1dfa6237b74ad35b79633502031c994a Mon Sep 17 00:00:00 2001 From: bovirus <1262554+bovirus@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:35:34 +0200 Subject: [PATCH 036/121] Update it.rs (#15531) --- src/lang/it.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/it.rs b/src/lang/it.rs index c93ab8019aa..ffd12ad6673 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connessione relay"), ("Secure Connection", "Connessione sicura"), ("Insecure Connection", "Connessione non sicura"), - ("Continue", ""), + ("Continue", "Continua"), ("Scale original", "Scala originale"), ("Scale adaptive", "Scala adattiva"), ("General", "Generale"), @@ -764,6 +764,6 @@ 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.\nContinuare comunque?"), + ("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?"), ].iter().cloned().collect(); } From 8314335b3124fb46fdea31ae1ce03b43d8db70d5 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 9 Jul 2026 15:05:49 +0800 Subject: [PATCH 037/121] fix: update download, force tls (#15529) Signed-off-by: fufesou --- src/hbbs_http.rs | 4 +- src/hbbs_http/downloader.rs | 4 +- src/hbbs_http/http_client.rs | 55 ++++++++++++++++++++ src/updater.rs | 99 ++++++++++++++++++++++++++++++++++-- 4 files changed, 153 insertions(+), 9 deletions(-) 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/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/updater.rs b/src/updater.rs index 56fdc1d2a8c..bf923dd56ed 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}, @@ -153,7 +153,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 +291,96 @@ 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) +} + +#[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}"); + } + } +} From 29e1852a68f4ab9a28aff6d6fb16aa590c6cb560 Mon Sep 17 00:00:00 2001 From: Zhenyu FU Date: Thu, 9 Jul 2026 15:23:43 +0800 Subject: [PATCH 038/121] fix: ci: macos: allow signed but not notarized dmg (#15530) * fix: ci: macos: allow signed but not notarized dmg Signed-off-by: Zhenyu FU * fix: ci: macos: add pre-check for macos identity Signed-off-by: Zhenyu FU * merge notarize checking to existing steps Signed-off-by: Zhenyu FU --- .github/workflows/flutter-build.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 2491789e6a4..036fd5bc724 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -39,7 +39,7 @@ env: # vcpkg version: 2025.08.27 # If we change the `VCPKG COMMIT_ID`, please remember: # 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`. - # Or we may face build issue like + # Or we may face build issue like # https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174 # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" @@ -49,6 +49,7 @@ env: #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" + MACOS_NOTARIZE_JSON: "${{ secrets.MACOS_NOTARIZE_JSON }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" @@ -615,7 +616,7 @@ jobs: run: | rustup target add ${{ matrix.job.target }} cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib - + - name: Upload liblibrustdesk.a Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -693,12 +694,20 @@ jobs: - name: Check sign and import sign key if: env.MACOS_P12_BASE64 != null + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + shell: bash run: | security default-keychain -s rustdesk.keychain security find-identity -v + if ! [[ "$MACOS_CODESIGN_IDENTITY" =~ ^[A-Za-z0-9]+$ ]]; then + # Ensure no whitespaces or special characters + echo 'FATAL: Invalid `secrets.MACOS_CODESIGN_IDENTITY` given. If signing key is stored on your Mac, you can run `security find-identity -v -p codesigning` to find out hex format of your identity.' >&2 + exit 128 + fi - name: Import notarize key - if: env.MACOS_P12_BASE64 != null + if: env.MACOS_P12_BASE64 != null && env.MACOS_NOTARIZE_JSON != null uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2 with: # https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling @@ -846,8 +855,10 @@ jobs: codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv - # notarize the rustdesk-${{ env.VERSION }}.dmg - rcodesign notary-submit --api-key-path ${{ github.workspace }}/rustdesk.json --staple rustdesk-${{ env.VERSION }}.dmg + if [ "$MACOS_NOTARIZE_JSON" != "" ]; then + # notarize the rustdesk-${{ env.VERSION }}.dmg + rcodesign notary-submit --api-key-path ${{ github.workspace }}/rustdesk.json --staple rustdesk-${{ env.VERSION }}.dmg + fi - name: Rename rustdesk if: env.UPLOAD_ARTIFACT == 'true' From 480e9e8234cf2d039617bef44bf00d6e0b590860 Mon Sep 17 00:00:00 2001 From: Vasyl Gello Date: Fri, 10 Jul 2026 06:38:43 +0300 Subject: [PATCH 039/121] Fix default Android API version mismatch between vcpkg and rest of build (for working on android 6) (#14850) * Fork vcpkg triplets to keep Android API version on 21 Fixes crash on API platforms 21 to 23 due to missing symbol `__write_chk` (available since API 24). Signed-off-by: Vasyl Gello * flutter/build_android_deps.sh: Refactor to remove unused ... variables and shellcheck warnings. Signed-off-by: Vasyl Gello --------- Signed-off-by: Vasyl Gello --- flutter/build_android_deps.sh | 123 +++++++++++----------- res/vcpkg-triplets/arm-neon-android.cmake | 7 ++ res/vcpkg-triplets/arm64-android.cmake | 7 ++ res/vcpkg-triplets/x64-android.cmake | 7 ++ res/vcpkg-triplets/x86-android.cmake | 7 ++ vcpkg.json | 3 + 6 files changed, 92 insertions(+), 62 deletions(-) create mode 100644 res/vcpkg-triplets/arm-neon-android.cmake create mode 100644 res/vcpkg-triplets/arm64-android.cmake create mode 100644 res/vcpkg-triplets/x64-android.cmake create mode 100644 res/vcpkg-triplets/x86-android.cmake 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/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/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": [ From 12b5cc7f725ad5510ccfaa8f28f15aece35c0d5a Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:47:13 +0800 Subject: [PATCH 040/121] Revert "fix: ci: macos: allow signed but not notarized dmg (#15530)" (#15551) This reverts commit 29e1852a68f4ab9a28aff6d6fb16aa590c6cb560. --- .github/workflows/flutter-build.yml | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 036fd5bc724..2491789e6a4 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -39,7 +39,7 @@ env: # vcpkg version: 2025.08.27 # If we change the `VCPKG COMMIT_ID`, please remember: # 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`. - # Or we may face build issue like + # Or we may face build issue like # https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174 # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" @@ -49,7 +49,6 @@ env: #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" - MACOS_NOTARIZE_JSON: "${{ secrets.MACOS_NOTARIZE_JSON }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" @@ -616,7 +615,7 @@ jobs: run: | rustup target add ${{ matrix.job.target }} cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib - + - name: Upload liblibrustdesk.a Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -694,20 +693,12 @@ jobs: - name: Check sign and import sign key if: env.MACOS_P12_BASE64 != null - env: - MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} - shell: bash run: | security default-keychain -s rustdesk.keychain security find-identity -v - if ! [[ "$MACOS_CODESIGN_IDENTITY" =~ ^[A-Za-z0-9]+$ ]]; then - # Ensure no whitespaces or special characters - echo 'FATAL: Invalid `secrets.MACOS_CODESIGN_IDENTITY` given. If signing key is stored on your Mac, you can run `security find-identity -v -p codesigning` to find out hex format of your identity.' >&2 - exit 128 - fi - name: Import notarize key - if: env.MACOS_P12_BASE64 != null && env.MACOS_NOTARIZE_JSON != null + if: env.MACOS_P12_BASE64 != null uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2 with: # https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling @@ -855,10 +846,8 @@ jobs: codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv - if [ "$MACOS_NOTARIZE_JSON" != "" ]; then - # notarize the rustdesk-${{ env.VERSION }}.dmg - rcodesign notary-submit --api-key-path ${{ github.workspace }}/rustdesk.json --staple rustdesk-${{ env.VERSION }}.dmg - fi + # notarize the rustdesk-${{ env.VERSION }}.dmg + rcodesign notary-submit --api-key-path ${{ github.workspace }}/rustdesk.json --staple rustdesk-${{ env.VERSION }}.dmg - name: Rename rustdesk if: env.UPLOAD_ARTIFACT == 'true' From 685a89a171ab88b78015a88f1a40095183bb72ea Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 10 Jul 2026 15:12:06 +0800 Subject: [PATCH 041/121] target Android 15 --- flutter/android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 830cbc2ddc1..2f4427376ed 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -82,7 +82,7 @@ protobuf { } android { - compileSdkVersion 34 + compileSdkVersion 35 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -99,7 +99,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.carriez.flutter_hbb" minSdkVersion 22 - targetSdkVersion 33 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } From 137298e05a10ebac2b12c933b7efebc7c217adec Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 10 Jul 2026 15:21:48 +0800 Subject: [PATCH 042/121] revert back --- flutter/android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 2f4427376ed..830cbc2ddc1 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -82,7 +82,7 @@ protobuf { } android { - compileSdkVersion 35 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -99,7 +99,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.carriez.flutter_hbb" minSdkVersion 22 - targetSdkVersion 35 + targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } From 865fe71c46e197f00272cb43c53ac076d241bfdd Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Sat, 11 Jul 2026 06:18:12 -0300 Subject: [PATCH 043/121] Update Portuguese translations for clarity (#15534) --- src/lang/ptbr.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 1b0e90a472c..45fc90821f5 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"), From 94a2a2bb4a82d1ac6a461e6c73aa10c3ed44a444 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sat, 11 Jul 2026 21:43:04 +0800 Subject: [PATCH 044/121] fix https://github.com/rustdesk/rustdesk/issues/15566 --- flutter/lib/desktop/pages/server_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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), ), ), From fa418cace616828c130068260f7c0311b1319ef1 Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Mon, 13 Jul 2026 07:56:41 -0300 Subject: [PATCH 045/121] Translate 'Continue' to 'Continuar' in ptbr.rs (#15567) Translate 'Continue' to 'Continuar' in ptbr.rs --- src/lang/ptbr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 45fc90821f5..4f2f8764b8d 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexão via Relay"), ("Secure Connection", "Conexão Segura"), ("Insecure Connection", "Conexão Insegura"), - ("Continue", ""), + ("Continue", "Continuar"), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptada"), ("General", "Geral"), From bdb38c4730f699ae1731533c3293af28e29b5e66 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 14 Jul 2026 15:35:52 +0800 Subject: [PATCH 046/121] fix: check valid id (#15535) Signed-off-by: fufesou --- src/common.rs | 37 +++++++++++++++++++++++++++++++++++++ src/lan.rs | 8 ++++++++ src/platform/windows.rs | 4 ++++ 3 files changed, 49 insertions(+) diff --git a/src/common.rs b/src/common.rs index b875d548cee..76aac387408 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2623,6 +2623,20 @@ 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::*; @@ -2653,6 +2667,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/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/platform/windows.rs b/src/platform/windows.rs index b6b5b39d9b8..161365cdcb1 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -2278,6 +2278,10 @@ 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 From cf2b28faf934fedb498c33b9746cd289426d8645 Mon Sep 17 00:00:00 2001 From: jinqiang zhang Date: Wed, 15 Jul 2026 13:27:15 +0800 Subject: [PATCH 047/121] fix linux llvm22 build (#15565) bindgen-0.65 is incompatible with llvm 22, we should upgrade to a newer bindgen version error message: ``` error[E0609]: no field `g_w` on type `vpx_codec_enc_cfg` --> libs/scrap/src/common/vpxcodec.rs:66:19 | 66 | c.g_w = config.width; | ^^^ unknown field | = note: available field is: `_address` ``` --- Cargo.lock | 32 ++++++++++++++++++++++++++------ libs/scrap/Cargo.toml | 2 +- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57177174db5..23cf35cbe4b 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" @@ -2329,7 +2349,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 +2714,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]] @@ -4494,7 +4514,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]] @@ -7434,7 +7454,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7491,7 +7511,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7578,7 +7598,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", diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2def8..0af7dfe0f66 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -48,7 +48,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] From 5abf4e9724dc9a8c9b45dfe13e71af11616eb2c8 Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 16 Jul 2026 16:00:38 +0800 Subject: [PATCH 048/121] Fix disabled installation bypass (#15598) * Fix disabled installation bypass Prevent install.exe and --install from opening the install flow when disable-installation is set. Signed-off-by: 21pages * Refine disabled installation handling for portable clients Document why --install must be filtered from both Rust and Flutter runner arguments for portable wrappers such as no-install.exe. Remove redundant UI- layer installation checks because the install entry points are already gated upstream. --------- Signed-off-by: 21pages --- flutter/windows/runner/main.cpp | 18 +++++++++++++++++- src/common.rs | 2 +- src/core_main.rs | 7 +++++++ src/flutter.rs | 6 ++++++ 4 files changed, 31 insertions(+), 2 deletions(-) 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/src/common.rs b/src/common.rs index 76aac387408..cd35433e0f1 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1024,7 +1024,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 { diff --git a/src/core_main.rs b/src/core_main.rs index 6b437a98845..3f2f0d24679 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); diff --git a/src/flutter.rs b/src/flutter.rs index e6b325cbeb6..a07d7c5987b 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 { From 96e2a330b86f1b2ffd31a70a9bb9565cace0d68f Mon Sep 17 00:00:00 2001 From: 21pages Date: Fri, 17 Jul 2026 15:52:49 +0800 Subject: [PATCH 049/121] restrict switch sides to remote desktop sessions (#15610) * fix: restrict switch sides to remote desktop sessions Reject switch sides requests outside authenticated remote desktop sessions, and reject switch sides responses that try to carry non-remote login types. Add scope coverage so file transfer, terminal, view camera, and port forward sessions cannot use switch sides. Signed-off-by: 21pages * fix review: consume switch sides UUID before rejecting response Signed-off-by: 21pages --------- Signed-off-by: 21pages --- src/server/connection.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index bdf9cba8b18..409d0e9fd1d 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2807,7 +2807,6 @@ 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() @@ -2816,6 +2815,15 @@ impl Connection { 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; self.from_switch = true; self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { @@ -5656,6 +5664,7 @@ impl Connection { 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", } @@ -6773,6 +6782,10 @@ mod test { 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())), @@ -6817,6 +6830,10 @@ mod test { 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), ( @@ -6902,6 +6919,10 @@ mod test { 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"), + ), ], ), ( @@ -6912,6 +6933,10 @@ mod test { None, ), (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + None, + ), ], ), ( @@ -6931,6 +6956,10 @@ mod test { 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), ( From 61f09449909241ad9b1ffa7d7c3ef47f4081a669 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 18 Jul 2026 16:32:58 +0800 Subject: [PATCH 050/121] Fix Adjust Window sizing across DPI (#15592) * Fix Adjust Window sizing across DPI and fullscreen transitions Signed-off-by: 21pages * Use visible screen frame for Adjust Window Signed-off-by: 21pages * Tolerate floating-point errors in Adjust Window sizing Signed-off-by: 21pages * Fix review, fix Adjust Window async metric guards Capture the current screen before awaiting window geometry so one target-frame calculation uses consistent screen metrics. Return early when adjusting without a context and the Flutter view list is empty, instead of calling views.first after the window or engine may have been torn down. Clarify the platform coordinate units used for Adjust Window scaling. * Fix Adjust Window for maximized Linux windows Unmaximize Linux remote windows before applying Adjust Window because native setFrame may be ignored while the window is maximized. * Fix Adjust Window screen refresh guards Refresh screen metrics before checking Adjust Window availability and again after exiting fullscreen so target-frame calculation uses current window geometry. Hide Adjust Window on web because resizing relies on desktop window APIs. * Fix review, handle missing window frame in Adjust Window Return null when WindowController.getFrame fails so Adjust Window availability checks and resize attempts skip cleanly if the window is hidden or disposed. --------- Signed-off-by: 21pages --- .../lib/desktop/widgets/remote_toolbar.dart | 138 +++++++++++++----- 1 file changed, 105 insertions(+), 33 deletions(-) diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f88f..6a4982daf85 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1348,7 +1348,7 @@ class ScreenAdjustor { adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(), + future: isWindowCanBeAdjusted(context), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1364,20 +1364,29 @@ class ScreenAdjustor { }); } - doAdjustWindow(BuildContext context) async { - await updateScreen(); - if (_screen != null) { - cbExitFullscreen(); - double scale = _screen!.scaleFactor; - final wndRect = await WindowController.fromWindowId(windowId).getFrame(); - final mediaSize = MediaQueryData.fromView(View.of(context)).size; - // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. + Future _getAdjustedWindowFrame(Size mediaSize) async { + final screen = _screen; + if (screen != null) { + // Windows window frames use physical pixels while Flutter view sizes are + // logical. macOS and Linux window frames use the same units as Flutter. + double scale = isWindows ? screen.scaleFactor : 1.0; + final Rect wndRect; + try { + wndRect = await WindowController.fromWindowId(windowId).getFrame(); + } catch (e) { + debugPrint( + "Failed to get frame of window $windowId, it may be hidden"); + return null; + } + // On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; final canvasModel = ffi.canvasModel; + // canvasModel.scale is the rendered scale and already applies kIgnoreDpi. + // Use it instead of the remote source resolution. final width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * @@ -1391,9 +1400,36 @@ class ScreenAdjustor { double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; - Rect frameRect = _screen!.frame; - if (!isFullscreen) { - frameRect = _screen!.visibleFrame; + // Adjust Window exits fullscreen before setting the window frame. + Rect frameRect = screen.visibleFrame; + if (isLinux && bind.mainCurrentIsWayland()) { + // In testing, Wayland at 200% reported an unscaled screen frame while + // GTK window sizes used logical units, so convert the frame first. + double screenScale = screen.scaleFactor; + if (screenScale > 1) { + frameRect = Rect.fromLTRB( + frameRect.left / screenScale, + frameRect.top / screenScale, + frameRect.right / screenScale, + frameRect.bottom / screenScale, + ); + } + } + // A window frame cannot be smaller than its client area. Tolerate small + // floating-point differences; larger negative values mean the native + // frame and Flutter view metrics are not synchronized. + if (magicWidth < -0.1 || magicHeight < -0.1) { + return null; + } + // Transient fullscreen metrics once produced a calculated 4.0x60.0 + // target frame. Reject implausibly small targets to avoid hiding the window. + if (width < 300 || height < 300) { + return null; + } + // The remote size may change after the menu is built. Require the target + // frame to be strictly smaller than the available screen area. + if (width >= frameRect.width || height >= frameRect.height) { + return null; } if (left < frameRect.left) { left = frameRect.left; @@ -1407,8 +1443,45 @@ class ScreenAdjustor { if ((top + height) > frameRect.bottom) { top = frameRect.bottom - height; } - await WindowController.fromWindowId(windowId) - .setFrame(Rect.fromLTWH(left, top, width, height)); + return Rect.fromLTWH(left, top, width, height); + } + return null; + } + + doAdjustWindow([BuildContext? context]) async { + // A resolution change is adjusted after a delay, when the menu context may + // already be disposed. Each desktop_multi_window window has its own engine, + // so that engine's first view is the current window. + final views = WidgetsBinding.instance.platformDispatcher.views; + if (context == null && views.isEmpty) { + return; + } + final view = context != null + ? View.of(context) + : views.first; + await updateScreen(); + if (_screen != null) { + final wc = WindowController.fromWindowId(windowId); + final wasFullscreen = isFullscreen; + cbExitFullscreen(); + if (wasFullscreen) { + // Wait for the native fullscreen exit to update the window frame. + await Future.delayed(Duration(milliseconds: 700)); + await updateScreen(); + } + if (isLinux) { + if (await wc.isMaximized()) { + // setFrame may be ignored while the native window is maximized. + await wc.unmaximize(); + stateGlobal.setMaximized(false); + } + } + final mediaSize = MediaQueryData.fromView(view).size; + final frame = await _getAdjustedWindowFrame(mediaSize); + if (frame == null) { + return; + } + await wc.setFrame(frame); stateGlobal.setMaximized(false); } } @@ -1438,7 +1511,20 @@ class ScreenAdjustor { return v.result; } - Future isWindowCanBeAdjusted() async { + Future isWindowCanBeAdjusted([BuildContext? context]) async { + if (isWeb) { + return false; + } + // Capture the view before awaiting because the menu context may be disposed. + final views = WidgetsBinding.instance.platformDispatcher.views; + if (context == null && views.isEmpty) { + return false; + } + final view = context != null + ? View.of(context) + : views.first; + final mediaSize = MediaQueryData.fromView(view).size; + await updateScreen(); final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { @@ -1453,23 +1539,7 @@ class ScreenAdjustor { if (_screen == null) { return false; } - final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; - double selfWidth = _screen!.visibleFrame.width; - double selfHeight = _screen!.visibleFrame.height; - if (isFullscreen) { - selfWidth = _screen!.frame.width; - selfHeight = _screen!.frame.height; - } - - final canvasModel = ffi.canvasModel; - final displayWidth = canvasModel.getDisplayWidth(); - final displayHeight = canvasModel.getDisplayHeight(); - final requiredWidth = - CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge; - final requiredHeight = - CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge; - return selfWidth > (requiredWidth * scale) && - selfHeight > (requiredHeight * scale); + return await _getAdjustedWindowFrame(mediaSize) != null; } } @@ -2177,7 +2247,9 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { } if (w == rect.width.toInt() && h == rect.height.toInt()) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - widget.screenAdjustor.doAdjustWindow(context); + // This delayed callback can outlive the menu State, so its context + // is unsafe. + widget.screenAdjustor.doAdjustWindow(); } } }); From 082a5a2a4ee80df65665b2271af460635edad884 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:37:30 +0800 Subject: [PATCH 051/121] Revert "Fix Adjust Window sizing across DPI (#15592)" (#15620) This reverts commit 61f09449909241ad9b1ffa7d7c3ef47f4081a669. --- .../lib/desktop/widgets/remote_toolbar.dart | 138 +++++------------- 1 file changed, 33 insertions(+), 105 deletions(-) diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 6a4982daf85..75fdbe1f88f 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1348,7 +1348,7 @@ class ScreenAdjustor { adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(context), + future: isWindowCanBeAdjusted(), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1364,29 +1364,20 @@ class ScreenAdjustor { }); } - Future _getAdjustedWindowFrame(Size mediaSize) async { - final screen = _screen; - if (screen != null) { - // Windows window frames use physical pixels while Flutter view sizes are - // logical. macOS and Linux window frames use the same units as Flutter. - double scale = isWindows ? screen.scaleFactor : 1.0; - final Rect wndRect; - try { - wndRect = await WindowController.fromWindowId(windowId).getFrame(); - } catch (e) { - debugPrint( - "Failed to get frame of window $windowId, it may be hidden"); - return null; - } - // On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect. + doAdjustWindow(BuildContext context) async { + await updateScreen(); + if (_screen != null) { + cbExitFullscreen(); + double scale = _screen!.scaleFactor; + final wndRect = await WindowController.fromWindowId(windowId).getFrame(); + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; final canvasModel = ffi.canvasModel; - // canvasModel.scale is the rendered scale and already applies kIgnoreDpi. - // Use it instead of the remote source resolution. final width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * @@ -1400,36 +1391,9 @@ class ScreenAdjustor { double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; - // Adjust Window exits fullscreen before setting the window frame. - Rect frameRect = screen.visibleFrame; - if (isLinux && bind.mainCurrentIsWayland()) { - // In testing, Wayland at 200% reported an unscaled screen frame while - // GTK window sizes used logical units, so convert the frame first. - double screenScale = screen.scaleFactor; - if (screenScale > 1) { - frameRect = Rect.fromLTRB( - frameRect.left / screenScale, - frameRect.top / screenScale, - frameRect.right / screenScale, - frameRect.bottom / screenScale, - ); - } - } - // A window frame cannot be smaller than its client area. Tolerate small - // floating-point differences; larger negative values mean the native - // frame and Flutter view metrics are not synchronized. - if (magicWidth < -0.1 || magicHeight < -0.1) { - return null; - } - // Transient fullscreen metrics once produced a calculated 4.0x60.0 - // target frame. Reject implausibly small targets to avoid hiding the window. - if (width < 300 || height < 300) { - return null; - } - // The remote size may change after the menu is built. Require the target - // frame to be strictly smaller than the available screen area. - if (width >= frameRect.width || height >= frameRect.height) { - return null; + Rect frameRect = _screen!.frame; + if (!isFullscreen) { + frameRect = _screen!.visibleFrame; } if (left < frameRect.left) { left = frameRect.left; @@ -1443,45 +1407,8 @@ class ScreenAdjustor { if ((top + height) > frameRect.bottom) { top = frameRect.bottom - height; } - return Rect.fromLTWH(left, top, width, height); - } - return null; - } - - doAdjustWindow([BuildContext? context]) async { - // A resolution change is adjusted after a delay, when the menu context may - // already be disposed. Each desktop_multi_window window has its own engine, - // so that engine's first view is the current window. - final views = WidgetsBinding.instance.platformDispatcher.views; - if (context == null && views.isEmpty) { - return; - } - final view = context != null - ? View.of(context) - : views.first; - await updateScreen(); - if (_screen != null) { - final wc = WindowController.fromWindowId(windowId); - final wasFullscreen = isFullscreen; - cbExitFullscreen(); - if (wasFullscreen) { - // Wait for the native fullscreen exit to update the window frame. - await Future.delayed(Duration(milliseconds: 700)); - await updateScreen(); - } - if (isLinux) { - if (await wc.isMaximized()) { - // setFrame may be ignored while the native window is maximized. - await wc.unmaximize(); - stateGlobal.setMaximized(false); - } - } - final mediaSize = MediaQueryData.fromView(view).size; - final frame = await _getAdjustedWindowFrame(mediaSize); - if (frame == null) { - return; - } - await wc.setFrame(frame); + await WindowController.fromWindowId(windowId) + .setFrame(Rect.fromLTWH(left, top, width, height)); stateGlobal.setMaximized(false); } } @@ -1511,20 +1438,7 @@ class ScreenAdjustor { return v.result; } - Future isWindowCanBeAdjusted([BuildContext? context]) async { - if (isWeb) { - return false; - } - // Capture the view before awaiting because the menu context may be disposed. - final views = WidgetsBinding.instance.platformDispatcher.views; - if (context == null && views.isEmpty) { - return false; - } - final view = context != null - ? View.of(context) - : views.first; - final mediaSize = MediaQueryData.fromView(view).size; - await updateScreen(); + Future isWindowCanBeAdjusted() async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { @@ -1539,7 +1453,23 @@ class ScreenAdjustor { if (_screen == null) { return false; } - return await _getAdjustedWindowFrame(mediaSize) != null; + final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; + double selfWidth = _screen!.visibleFrame.width; + double selfHeight = _screen!.visibleFrame.height; + if (isFullscreen) { + selfWidth = _screen!.frame.width; + selfHeight = _screen!.frame.height; + } + + final canvasModel = ffi.canvasModel; + final displayWidth = canvasModel.getDisplayWidth(); + final displayHeight = canvasModel.getDisplayHeight(); + final requiredWidth = + CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge; + final requiredHeight = + CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge; + return selfWidth > (requiredWidth * scale) && + selfHeight > (requiredHeight * scale); } } @@ -2247,9 +2177,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { } if (w == rect.width.toInt() && h == rect.height.toInt()) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - // This delayed callback can outlive the menu State, so its context - // is unsafe. - widget.screenAdjustor.doAdjustWindow(); + widget.screenAdjustor.doAdjustWindow(context); } } }); From 5f015c9da13cb227a414c6d295a5c81e5360eccb Mon Sep 17 00:00:00 2001 From: cui fliter Date: Sat, 18 Jul 2026 17:56:27 +0800 Subject: [PATCH 052/121] Translate Continue into Simplified Chinese (#15621) Signed-off-by: cuishuang --- src/lang/cn.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/cn.rs b/src/lang/cn.rs index aac57d011e5..b030f086d99 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中继连接"), ("Secure Connection", "安全连接"), ("Insecure Connection", "非安全连接"), - ("Continue", ""), + ("Continue", "继续"), ("Scale original", "原始尺寸"), ("Scale adaptive", "适应窗口"), ("General", "常规"), From c01300be201525afd73d6ac3fcf19a2d8b74a65d Mon Sep 17 00:00:00 2001 From: CHarris Date: Sun, 19 Jul 2026 22:22:46 -0400 Subject: [PATCH 053/121] fix(ipc): never adopt an empty id from the main IPC (#15626) --- src/ipc.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/ipc.rs b/src/ipc.rs index 68c987f4ece..01f4cda6e95 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1689,19 +1689,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) { From 20ab5ab0ad78a6ec6b92375f4cc483415ad75ff9 Mon Sep 17 00:00:00 2001 From: CHarris Date: Sun, 19 Jul 2026 22:58:28 -0400 Subject: [PATCH 054/121] fix(deploy): don't wipe local id when --deploy gets an empty --id (#15633) `rustdesk --deploy --id ""` (e.g. an unset variable in a deployment script) deploys a blank id, then wipes the local id and unconfirms the key through the IPC config write. The Android deploy flow already guards an empty id (#15146); apply the same guard to the CLI, and reject an empty id at the IPC write boundary the same way the read path was fixed in #15626. --- src/core_main.rs | 3 ++- src/ipc.rs | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core_main.rs b/src/core_main.rs index 3f2f0d24679..b20ecd92be5 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -667,7 +667,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."); diff --git a/src/ipc.rs b/src/ipc.rs index 01f4cda6e95..e4b92be5fcb 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -881,8 +881,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" { From 7696b0ee51a82133305cbcafec6fb1523f9df415 Mon Sep 17 00:00:00 2001 From: gateslu Date: Mon, 20 Jul 2026 13:24:48 +0800 Subject: [PATCH 055/121] fix(linux): forward forced display server to user server (#15627) Signed-off-by: Gateslu --- src/platform/linux.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9a4bb37ecb2..ab6b1879bef 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -646,6 +646,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()), From 5b4d6baf47283068fbe03f2eacd2ec34d0c20c2c Mon Sep 17 00:00:00 2001 From: Kuksgauzen <47536339+Kuksgauzen@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:42:43 +0400 Subject: [PATCH 056/121] fix: wrap BackingScaleFactor in autoreleasepool to stop NSDictionary accumulation on macOS (#15623) Signed-off-by: Viktor Kuksgauzen --- src/platform/macos.mm | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 From 1c2dd71891cd476a8952eab95d682cd33f3188c9 Mon Sep 17 00:00:00 2001 From: hatterp Date: Tue, 21 Jul 2026 16:43:09 +0200 Subject: [PATCH 057/121] Translate 'Continue' to 'Kontynuuj' in Polish (#15641) --- src/lang/pl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 9de6cfd9217..1123580a095 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Połączenie przez bramkę"), ("Secure Connection", "Połączenie szyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"), - ("Continue", ""), + ("Continue", "Kontynuuj"), ("Scale original", "Skalowanie oryginalne"), ("Scale adaptive", "Dopasuj do wyświetlacza"), ("General", "Ogólne"), From 929e989f17ba92e1a8291c0616f705208b67e3ea Mon Sep 17 00:00:00 2001 From: bmmh1 Date: Wed, 22 Jul 2026 11:22:10 -0500 Subject: [PATCH 058/121] feat(macos): silent auto-update with security hardening (#15550) Co-authored-by: bmmh1 --- .../desktop/pages/desktop_setting_page.dart | 3 +- src/ipc.rs | 25 +- src/ipc/auth.rs | 26 + src/platform/macos.rs | 906 +++++++++++++++++- src/platform/privileges_scripts/daemon.plist | 4 +- src/platform/privileges_scripts/install.scpt | 10 +- src/service.rs | 8 + src/updater.rs | 316 +++++- 8 files changed, 1276 insertions(+), 22 deletions(-) diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 8cd640f9706..e2e557437a9 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -485,7 +485,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', diff --git a/src/ipc.rs b/src/ipc.rs index e4b92be5fcb..4498ceb5fc5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -41,6 +41,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, @@ -472,6 +474,8 @@ 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), @@ -1006,6 +1010,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")) @@ -1340,14 +1354,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")] diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 77fd148c6cb..0dd43855eec 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -656,6 +656,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/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/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/updater.rs b/src/updater.rs index bf923dd56ed..beab97e5375 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -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)) { @@ -348,6 +409,251 @@ 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; From beaa754299feff6d89a89e2e9f71d838788e333a Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 23 Jul 2026 17:17:01 +0800 Subject: [PATCH 059/121] fix stale primary display selection (#15460) * fix stale primary display selection Signed-off-by: 21pages * fix stale display selection during login and switching - resolve the primary display from the refreshed login snapshot - defer display enumeration until authentication succeeds - read Wayland displays and primary index from the same cache snapshot - reject stale monitor and camera indices during display switching Signed-off-by: 21pages * fix inconsistent display snapshots during login - return displays from the same enumeration used to select the primary - avoid re-reading the shared display cache after updating it - use the same converted snapshot during Wayland initialization Signed-off-by: 21pages * avoid cloning unchanged display snapshots Signed-off-by: 21pages * fix invalid display subset handling Signed-off-by: 21pages * minimize code churn in switch_display_to Signed-off-by: 21pages --------- Signed-off-by: 21pages --- src/server.rs | 30 +++++------ src/server/connection.rs | 99 ++++++++++++++++++++++++++++------- src/server/display_service.rs | 78 +++++++++++++++------------ src/server/wayland.rs | 21 ++------ 4 files changed, 143 insertions(+), 85 deletions(-) diff --git a/src/server.rs b/src/server.rs index 89a17a91963..f02a15a7faa 100644 --- a/src/server.rs +++ b/src/server.rs @@ -357,15 +357,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 +379,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 _)) { @@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { 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")))] @@ -2006,8 +2010,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); } } } @@ -4150,7 +4154,9 @@ 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 !self.view_camera && s.width != 0 && s.height != 0 { @@ -4177,6 +4183,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 @@ -4185,18 +4198,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. @@ -4205,6 +4228,7 @@ impl Connection { } lock.subscribe(&new_service_name, self.inner.clone(), true); self.display_idx = display_idx; + true } #[cfg(windows)] @@ -4231,26 +4255,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 { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index fe3621f26a8..946952ccd45 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -25,9 +25,6 @@ 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(); } @@ -41,22 +38,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; } } @@ -304,6 +293,11 @@ pub(super) fn get_display_info(idx: usize) -> Option { // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { + let _ = update_sync_displays(all); +} + +// 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")] @@ -346,7 +340,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 +352,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 +485,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/wayland.rs b/src/server/wayland.rs index 1e0efc0f480..7927096a675 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -175,8 +175,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 +219,15 @@ pub(super) async fn check_init() -> ResultType<()> { Ok(()) } -pub(super) async fn get_displays() -> ResultType> { +pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { 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.displays.clone()) - } - } else { - bail!("Failed to get capturer display info"); - } -} - -pub(super) fn get_primary() -> ResultType { - 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"); From b4af82157bc5b44b62e66c1e7b50cc945bc42532 Mon Sep 17 00:00:00 2001 From: CHarris Date: Fri, 24 Jul 2026 06:35:49 -0400 Subject: [PATCH 060/121] fix: refresh wayland uinput range on compositor layout change (#15628) * fix: refresh wayland uinput range on compositor layout change The uinput absolute range is computed once at session init. If the compositor layout changes mid-session (monitor scale or position change, or a portal virtual output appearing once capture starts), injected coordinates are rescaled by the stale range and land offset. Poll the live desktop bounding box from the display service loop while subscribed (one wayland roundtrip, throttled to 1.5s, no subprocesses) and re-apply the uinput resolution when it changes. Also read a fresh layout when computing the initial range in check_init, since the cache is not cleared when a session closes through the restore-token path. This is the X component of #15601. The stale advertised origins (the Y component) are not touched here: re-advertising DisplayInfo mid-session trips the portal re-negotiation and can drop displays. Signed-off-by: Cody Harris * fix: bound the mouse resolution IPC wait during session init Wrap update_mouse_resolution in the same 3s timeout the periodic refresh uses, so a hung IPC response can't stall check_init. Co-Authored-By: Claude Fable 5 * fix: build timeout future inside runtime, split linux lazy_static Constructing the timeout future eagerly as the block_on argument panics with 'there is no reactor running'; move it into the async block so it is built inside the runtime context. Also move WAYLAND_UINPUT_RECT into its own cfg-gated lazy_static block, an attribute on a single item inside the shared block does not compile. * fix: confirm uinput mouse device adopted new range before caching rect send_refresh() now waits for the mouse service to ack that it recreated the device with the new range instead of firing and forgetting, and update_mouse_resolution() propagates that result. The layout poller only caches the rect after the device actually adopts the range, so a failed refresh errors and retries on the next check. The ack read is bounded by IPC_REQUEST_TIMEOUT, matching the keyboard get-key-state path. * fix: propagate refresh failures instead of caching a stale range - input_service: error when the custom-mouse downcast fails so the poller retries instead of caching an unconfirmed refresh - uinput: on device recreation failure, keep the current device and the IPC connection and withhold the ack so the client retries, instead of killing the mouse handler * fix: remap injected wayland coords onto the live layout after a monitor moves The range refresh corrects the uinput ABS bounds, but a single-display client sends whole-desktop coordinates offset by the origin of the display it follows, taken from the layout advertised at session init. When another monitor is rescaled or moved that origin shifts, so the coordinate lands offset before it reaches uinput and the range refresh cannot recover it. Snapshot the per-display layout at init, poll the live layout on the existing 1.5s throttle, and when they differ remap each injected move into the followed display's current rectangle (matched by connector name, index fallback when the compositor reports none). No-op and lock-free while the layout is unchanged. --------- Signed-off-by: Cody Harris Co-authored-by: Claude Fable 5 --- libs/scrap/src/wayland/display.rs | 315 +++++++++++++++++++++++++++++- src/server/display_service.rs | 144 ++++++++++++++ src/server/input_service.rs | 33 +++- src/server/uinput.rs | 34 +++- src/server/wayland.rs | 28 ++- 5 files changed, 531 insertions(+), 23 deletions(-) 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/src/server/display_service.rs b/src/server/display_service.rs index 946952ccd45..8531076a9fe 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -28,6 +28,144 @@ lazy_static::lazy_static! { 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); +} + +#[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; + } + { + 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); @@ -231,6 +369,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)); } diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 91a2901dc14..1d4deeb65db 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -661,20 +661,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 +1100,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/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/wayland.rs b/src/server/wayland.rs index 7927096a675..dacce9485ae 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -137,6 +137,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 +150,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"); } From ad9dac100102008ba1ae20067c0a4dac0fc6847c Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 25 Jul 2026 09:36:25 +0800 Subject: [PATCH 061/121] fix(keyboard): jis, macos, muhenkan henkan (#15669) Signed-off-by: fufesou --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 23cf35cbe4b..78ff9eb463b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6940,7 +6940,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", From cefff781d4994a306452dcd584336ee0896e2e15 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 25 Jul 2026 15:21:13 +0800 Subject: [PATCH 062/121] feat(recording): add visibility and service storage options (#15662) * feat(recording): add visibility and service storage options - support hide-recording-button in Flutter and Sciter - allow a custom save directory for Windows service recordings - sanitize peer IDs used in recording filenames Tested: - with hide-recording-button=Y and allow-auto-record-outgoing=Y, outgoing sessions are recorded automatically while the recording button remains hidden and cannot be stopped from the UI; verified on Flutter desktop, Sciter, and Android - windows-service-video-save-directory takes effect when the Windows client runs as an installed service - the Windows controlling side can save recordings for direct IP:port connections Signed-off-by: 21pages * update hbb_common Signed-off-by: 21pages * fix(recording): validate configured save directories - trim configured recording directory paths - reject non-absolute paths and fall back to defaults - warn when a non-empty path is invalid Signed-off-by: 21pages * fix(recording): validate configured save directories Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/common/widgets/toolbar.dart | 1 + flutter/lib/consts.dart | 1 + .../lib/desktop/widgets/remote_toolbar.dart | 4 +- libs/hbb_common | 2 +- libs/scrap/src/common/record.rs | 39 +++++++++- src/ui/header.tis | 2 +- src/ui/remote.rs | 5 ++ src/ui/remote.tis | 1 + src/ui_interface.rs | 74 ++++++++++++++++++- 9 files changed, 123 insertions(+), 6 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 83638000b3d..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( diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 69f4be59ea9..722f7a23cd1 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -104,6 +104,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"; diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f88f..8f589b79a21 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2740,7 +2740,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/libs/hbb_common b/libs/hbb_common index 7e1c392c62d..559176122bd 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 +Subproject commit 559176122bdd5c8afa4e8fd5b706c3d901fb0c15 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/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/remote.rs b/src/ui/remote.rs index 1d5ceb139d2..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); @@ -638,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_interface.rs b/src/ui_interface.rs index 1a892784072..94fde439263 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -911,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(); @@ -930,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",)); @@ -941,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() { @@ -1705,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); + } +} From 57456f0b52a50888e60b218f709af06f2cb1b205 Mon Sep 17 00:00:00 2001 From: dongrencd <903151724@qq.com> Date: Sat, 25 Jul 2026 22:33:16 +0800 Subject: [PATCH 063/121] feat(terminal): add Ctrl and Alt toggles to mobile terminal keyboard (#15532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(terminal): add Ctrl toggle and Ctrl+X shortcut keys to mobile terminal floating keyboard Signed-off-by: dongrencd * refactor(terminal): restructure keyboard layout with collapse button - Move | from Row1 position 3 to Row1 end (aligned with collapse button) - Remove ~ from Row2, add collapse button (∨/∧) after PgDn - Row3: conditional render, add ~ and -, remove trailing placeholders - Collapse state persisted via kOptionEnableShowTerminalCtrlKeys - Row3 defaults to collapsed for compact layout Signed-off-by: dongrencd * fix(terminal): restore trailing placeholders in Row3 for alignment Row3 needs trailing placeholders to match Row1/Row2 width (348px) so Ctrl aligns with Tab in Row2 and Esc in Row1. Signed-off-by: dongrencd * fix(terminal): update mobile keyboard layout per review Signed-off-by: dong.ren.cd * fix(terminal): address mobile keyboard review regressions Signed-off-by: dong.ren.cd * fix(terminal): preserve ctrl-j newline mapping on mobile Signed-off-by: dong.ren.cd * fix(terminal): preserve pasted input with modifiers Signed-off-by: dong.ren.cd * fix(terminal): harden mobile modifier and paste input Signed-off-by: dong.ren.cd * fix(terminal): harden mobile paste shortcut handling Signed-off-by: dong.ren.cd * fix(terminal): preserve unicode graphemes under ctrl * fix(terminal): avoid modifier scan for inactive locks * fix(terminal): keep default hardware paste shortcuts * fix(terminal): guard hardware paste with modifier locks * fix(terminal): update mobile key button color role --------- Signed-off-by: dongrencd Signed-off-by: dong.ren.cd Co-authored-by: dongrencd Co-authored-by: dong.ren.cd --- flutter/lib/consts.dart | 1 + flutter/lib/mobile/pages/terminal_page.dart | 231 +++++++++-- .../lib/mobile/terminal_keyboard_utils.dart | 20 + flutter/lib/models/input_modifier_utils.dart | 152 +++++++ flutter/lib/models/terminal_model.dart | 86 +++- flutter/test/input_modifier_utils_test.dart | 390 ++++++++++++++++++ .../test/terminal_keyboard_utils_test.dart | 40 ++ .../test/terminal_model_lifecycle_test.dart | 51 +++ 8 files changed, 926 insertions(+), 45 deletions(-) create mode 100644 flutter/lib/mobile/terminal_keyboard_utils.dart create mode 100644 flutter/test/terminal_keyboard_utils_test.dart create mode 100644 flutter/test/terminal_model_lifecycle_test.dart diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 722f7a23cd1..ce5441ddfbb 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -178,6 +178,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/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index cbf47a7e992..a4a76f9af0f 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -5,8 +5,11 @@ 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:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; import '../../desktop/pages/terminal_connection_manager.dart'; @@ -42,6 +45,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; @@ -94,6 +102,18 @@ class _TerminalPageState extends State // 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 @@ -148,6 +168,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); @@ -185,6 +238,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; @@ -193,11 +247,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(); } }, ); @@ -324,66 +374,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': @@ -427,9 +582,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/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 2b3fd48373b..6f179afe299 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -7,6 +7,7 @@ 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'; @@ -22,7 +23,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 = []; @@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier { 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. @@ -49,13 +72,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 { @@ -176,6 +230,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 { @@ -516,6 +582,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/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..d00646b2ba9 --- /dev/null +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -0,0 +1,51 @@ +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'; + +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); + }); +} From b1fad7bbed5f736e34c7a718ecc4f54c3c33f0aa Mon Sep 17 00:00:00 2001 From: FrederickStempfle Date: Sun, 26 Jul 2026 02:56:22 +0200 Subject: [PATCH 064/121] fix: validate RGBA clipboard dimensions (#15672) --- src/clipboard.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) 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; From 5882346caa9211c16180be6540b72d671fd71215 Mon Sep 17 00:00:00 2001 From: FrederickStempfle Date: Sun, 26 Jul 2026 03:04:20 +0200 Subject: [PATCH 065/121] fix: validate remote audio channel count (#15673) --- src/client.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/client.rs b/src/client.rs index dcd5941dfbe..5cafeadaf71 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1401,6 +1401,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 +1544,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, From eefd22b2057ba057305b10a6b5bf93c79b686eb9 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 26 Jul 2026 11:54:01 +0800 Subject: [PATCH 066/121] fix(macos): prevent remote keyboard focus leaks (#15629) * fix(macos): prevent remote keyboard focus leaks Gate keyboard grabbing on window, tab, lifecycle, and primary focus state. Release grabs on focus loss or minimize and avoid duplicate grab transitions. Signed-off-by: fufesou * fix: macos, keyboard focus, comments known issue Signed-off-by: fufesou * fix: macos, keyboard, fullscreen space switch Signed-off-by: fufesou * fix: macos, keyboard, focus, relative mouse mode Signed-off-by: fufesou * fix(macOS): preserve local overlay focus during input recovery Prevent fullscreen and relative-mouse focus recovery from reclaiming remote keyboard input while a local chat or dialog overlay owns focus. Signed-off-by: fufesou * fix: macos, keyboard, comments trade-off Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .../macos_full_screen_focus_recovery.dart | 24 ++ flutter/lib/desktop/pages/remote_page.dart | 321 +++++++++++++++++- .../lib/desktop/pages/remote_tab_page.dart | 6 +- .../lib/desktop/widgets/remote_toolbar.dart | 2 + 4 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart 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..a9185d6a309 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(); @@ -231,19 +264,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 +495,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 +590,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 +608,7 @@ class _RemotePageState extends State super.onWindowEnterFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(true); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -346,6 +617,7 @@ class _RemotePageState extends State super.onWindowLeaveFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(false); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -354,6 +626,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 +648,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 +725,8 @@ class _RemotePageState extends State } else { _ffi.inputModel.enterOrLeave(false); } + } else if (isMacOS) { + _onMacOSFocusChange(); } }, inputModel: _ffi.inputModel, @@ -549,7 +832,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 +862,9 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _syncMacOSKeyboardGrab(); + } else if (!isWindows) { _ffi.inputModel.enterOrLeave(false); } } @@ -600,17 +889,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/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 8f589b79a21..2373d016a98 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2484,6 +2484,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(); } From d6ea170061576546d64777ddb0f4ce93efa051e3 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:24:32 +0800 Subject: [PATCH 067/121] Id whitelist (#15586) * id whitelist * hbb_common * Update flutter/lib/common/widgets/dialog.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * support wss:// for web client Signed-off-by: 21pages * fix: handle ID copying separately and remove whitelist logs Signed-off-by: 21pages * fix en translation Signed-off-by: 21pages * fix: check switch-side ID whitelist after login initialization Signed-off-by: 21pages * track pending 2FA challenge state Signed-off-by: 21pages * support Unicode IDs in whitelist settings Signed-off-by: 21pages * refactor: unify client ID resolution Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: 21pages --- AGENTS.md | 6 + flutter/lib/common.dart | 14 ++ flutter/lib/common/widgets/dialog.dart | 109 ++++++++++ flutter/lib/consts.dart | 1 + .../desktop/pages/desktop_setting_page.dart | 55 ++++- flutter/lib/mobile/pages/settings_page.dart | 40 ++++ src/client.rs | 3 - src/lang/ar.rs | 9 + src/lang/be.rs | 9 + src/lang/bg.rs | 9 + src/lang/ca.rs | 9 + src/lang/cn.rs | 9 + src/lang/cs.rs | 9 + src/lang/da.rs | 9 + src/lang/de.rs | 9 + src/lang/el.rs | 9 + src/lang/en.rs | 7 +- src/lang/eo.rs | 9 + src/lang/es.rs | 9 + src/lang/et.rs | 9 + src/lang/eu.rs | 9 + src/lang/fa.rs | 9 + src/lang/fi.rs | 9 + src/lang/fr.rs | 9 + src/lang/ge.rs | 9 + src/lang/gu.rs | 9 + src/lang/he.rs | 9 + src/lang/hi.rs | 9 + src/lang/hr.rs | 9 + src/lang/hu.rs | 9 + src/lang/id.rs | 9 + src/lang/it.rs | 9 + src/lang/ja.rs | 9 + src/lang/ko.rs | 9 + src/lang/kz.rs | 9 + src/lang/lt.rs | 9 + src/lang/lv.rs | 9 + src/lang/ml.rs | 9 + src/lang/nb.rs | 9 + src/lang/nl.rs | 9 + src/lang/pl.rs | 9 + src/lang/pt_PT.rs | 9 + src/lang/ptbr.rs | 9 + src/lang/ro.rs | 9 + src/lang/ru.rs | 9 + src/lang/sc.rs | 9 + src/lang/sk.rs | 9 + src/lang/sl.rs | 9 + src/lang/sq.rs | 9 + src/lang/sr.rs | 9 + src/lang/sv.rs | 9 + src/lang/ta.rs | 9 + src/lang/template.rs | 9 + src/lang/th.rs | 9 + src/lang/tr.rs | 9 + src/lang/tw.rs | 9 + src/lang/uk.rs | 9 + src/lang/vi.rs | 9 + src/server/connection.rs | 188 +++++++++++++++++- 59 files changed, 866 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9a3518f50a..4f0afd4c178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,3 +84,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/flutter/lib/common.dart b/flutter/lib/common.dart index 1651f670189..cb3faf16321 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -3124,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) { @@ -4004,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. 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/consts.dart b/flutter/lib/consts.dart index ce5441ddfbb..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"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index e2e557437a9..b2aab1cfbbb 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -1298,6 +1298,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, @@ -1455,6 +1456,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, @@ -2415,17 +2462,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( @@ -2447,6 +2497,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'); 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/src/client.rs b/src/client.rs index 5cafeadaf71..f711c227c72 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2671,9 +2671,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(); diff --git a/src/lang/ar.rs b/src/lang/ar.rs index b73147b3562..b0c695b812f 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 5c61fe4f599..1c726b71ad0 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 30d01b381ce..43380a92bb5 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 1d02225b8c4..001d12b7f17 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index b030f086d99..dff0a2e2d25 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 91cf8a6c174..420913038f2 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index ab057404a6e..f7579b22b60 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 8536ebf4264..08f4673660e 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 16865bcf6c7..e3fc945648a 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 171a5dd44d0..fcd68a3008f 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -279,6 +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?") + ("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 28ab2f16501..7af41cd3f6c 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 1271e49226f..1e592934a3e 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 39ec3cce47e..9eccaa6c2a7 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index bfc497253d0..a3b752a502b 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 3b46099f049..3cdbcb3bc32 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 91a2f714d06..0edd04d4597 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 595c0efb558..aa822413fab 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index d3c0c0b608f..b3fe30dd9b3 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 9922654360a..a150047d72b 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 19cab0e7153..dfe37733d48 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 4c2c111098c..a146053dff5 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 2323d37d207..220bafac5c9 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 11c6db083a5..733598c3fdd 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 2dfb30b9be1..0482df42584 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index ffd12ad6673..b002381ef50 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 4385f9b2672..9494d19f36f 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 386d7dc05d9..186476a3ced 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -764,5 +764,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 1c47ebb6243..419d80de25d 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index a0e9dab3891..012ec13162b 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 308e2fb1368..ee901974bf0 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 8c69c7b442a..b6faa4655fa 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index ab6eefe4931..b15f1c59fb3 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 21288a9d8a2..2ab9e3baa24 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("All monitors", "Alle monitoren"), ("#{} monitor", "Monitor {}"), ("conn-e2ee-unavailable-tip", "End-to-endversleuteling kon niet worden geverifieerd.\nHet externe apparaat wordt mogelijk nog ingesteld. Probeer het later opnieuw.\nAls dit blijft gebeuren, is de server mogelijk niet vertrouwd.\nToch doorgaan?"), + ("ID whitelisting", "ID Witte Lijst"), + ("Use ID whitelisting", "Gebruik een witte lijst van ID's"), + ("id_whitelist_tip", "Alleen ID's op de witte lijst krijgen toegang tot mijn toestel"), + ("id_whitelist_wildcard_tip", "Jokertekens worden ondersteund: '*' komt overeen met een willekeurig aantal tekens, '?' met precies één teken"), + ("Invalid ID", "Ongeldig ID"), + ("Your ID is blocked by the peer", "Je ID is geblokkeerd door de andere partij"), + ("Your ip is blocked by the peer", "Je IP-adres is geblokkeerd door de andere partij"), + ("id_whitelist_caveat_tip", "Het ID wordt gemeld door de verbindende client. De witte lijst vermindert blootstelling en vervangt het wachtwoord of 2FA niet"), + ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijvoorbeeld 192.168.1.0/24"), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 1123580a095..8971a31b611 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index fabe3742458..e730efcb626 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 4f2f8764b8d..07d72b26846 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index a828db9c85c..c703af64820 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 1fa53a6a4bc..b808b5cd3eb 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 43829f69b17..cbe9103d1f5 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index d2700a52e02..d67c7b86639 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index a9e4b6b1383..6bcea909718 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index f8b30bbc320..33024889062 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index ecbaed7acd9..43eb13b90b9 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 42d9c5f1ec5..d3b04793f8e 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 16a5cd1a170..3b17828956b 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index faabf087605..24e3a5062ae 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index a905ec863d6..17a050e8449 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 58875d61929..955ec967303 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 2771c21ce55..71b853e99c2 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 5996eedfbd9..5de8a557251 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 48b567da732..d32c7ff2e59 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -765,5 +765,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), ].iter().cloned().collect(); } diff --git a/src/server/connection.rs b/src/server/connection.rs index 6656f1cc5a8..c6d67e5fc5d 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -73,8 +73,14 @@ 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; + lazy_static::lazy_static! { - static ref LOGIN_FAILURES: [Arc::>>; 2] = Default::default(); + // [0] password, [1] 2FA, [2] ID whitelist. + // The ID whitelist has its own bucket: its counters never decay, and a peer rejected by + // it can never clear them with a successful login, so sharing the password bucket would + // let a rejected ID lock out whitelisted peers behind the same IP / IPv6 prefix. + 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(); @@ -317,6 +323,7 @@ pub struct Connection { tx_to_cm: mpsc::UnboundedSender, authorized: bool, require_2fa: Option, + awaiting_2fa: bool, keyboard: bool, clipboard: bool, audio: bool, @@ -503,6 +510,7 @@ impl Connection { tx_video: Some(tx_video), }, require_2fa: crate::auth_2fa::get_2fa(None), + 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, @@ -1375,6 +1383,32 @@ 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_allows(&id_whitelist, &self.lr.my_id) { + return true; + } + // Rate limit rejections so that the whitelist can not be used as an oracle to + // enumerate allowed IDs. Whitelisted peers return above without touching this + // bucket, so a rejected ID can not lock them out. + let (failure, res) = self.check_failure(FAILURE_IDX_ID_WHITELIST).await; + if !res { + return false; + } + 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 + } + async fn on_open(&mut self, addr: SocketAddr) -> bool { log::debug!("#{} Connection opened from {}.", self.inner.id, addr); if !self.check_whitelist(&addr).await { @@ -1508,7 +1542,7 @@ 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 { + if typ == AlarmAuditType::IpWhitelist || typ == AlarmAuditType::IdWhitelist { if let Some(audit_ref) = self.conn_audit_ref() { v["conn_audit_ref"] = json!(audit_ref); } @@ -1646,10 +1680,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; } @@ -2527,11 +2563,15 @@ impl Connection { } // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { + 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( @@ -2756,6 +2796,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; @@ -2828,6 +2873,11 @@ impl Connection { } 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 { @@ -6125,6 +6175,7 @@ pub enum AlarmAuditType { TerminalOsLoginBackoff = 7, TerminalOsLoginConcurrency = 8, SessionScopeViolation = 9, + IdWhitelist = 10, } pub enum FileAuditType { @@ -6742,11 +6793,144 @@ 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)) +} + +// 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] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some((pi + 1, ti)); + pi += 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::*; + #[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("123*", "123456789")); + assert!(wildcard_match("123*", "123")); + 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_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() { From dabdbf73bb80f1718879fe879619d83c72103041 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 28 Jul 2026 00:09:45 +0800 Subject: [PATCH 068/121] improve id wildcast --- src/server/connection.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index c6d67e5fc5d..353a06e4831 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -6817,12 +6817,12 @@ fn wildcard_match(pattern: &str, text: &str) -> bool { let (mut pi, mut ti) = (0, 0); let mut star: Option<(usize, usize)> = None; while ti < t.len() { - if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { - pi += 1; - ti += 1; - } else if pi < p.len() && p[pi] == '*' { + 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; @@ -6854,8 +6854,10 @@ mod test { // '*' 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")); From 4dd8e203922f2bc3898ddd06a098617381d36674 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 28 Jul 2026 13:36:35 +0800 Subject: [PATCH 069/121] improve id whitelist login failures --- src/server/connection.rs | 125 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 7 deletions(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index 353a06e4831..4dc07d366f1 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -74,12 +74,14 @@ 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! { // [0] password, [1] 2FA, [2] ID whitelist. - // The ID whitelist has its own bucket: its counters never decay, and a peer rejected by - // it can never clear them with a successful login, so sharing the password bucket would - // let a rejected ID lock out whitelisted peers behind the same IP / IPv6 prefix. + // 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(); @@ -1389,16 +1391,20 @@ impl Connection { .map(|x| x.trim().to_owned()) .filter(|x| !x.is_empty()) .collect(); - if id_whitelist_allows(&id_whitelist, &self.lr.my_id) { + if id_whitelist.is_empty() { return true; } - // Rate limit rejections so that the whitelist can not be used as an oracle to - // enumerate allowed IDs. Whitelisted peers return above without touching this - // bucket, so a rejected ID can not lock them out. + // 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; @@ -1409,6 +1415,34 @@ impl Connection { 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 { @@ -1693,6 +1727,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() { @@ -6809,6 +6846,32 @@ fn id_whitelist_allows(id_whitelist: &[String], my_id: &str) -> bool { .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 { @@ -6873,6 +6936,54 @@ mod test { 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::>(); From d412d198720aa56f6cfed2dfad262e8fb1322fb7 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Tue, 28 Jul 2026 13:38:36 +0800 Subject: [PATCH 070/121] aligned_u8_vec --- libs/hbb_common | 2 +- src/server/audio_service.rs | 37 ++++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index 559176122bd..69cea8dafee 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 559176122bdd5c8afa4e8fd5b706c3d901fb0c15 +Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 diff --git a/src/server/audio_service.rs b/src/server/audio_service.rs index d1bb2d87842..b58f83bcf15 100644 --- a/src/server/audio_service.rs +++ b/src/server/audio_service.rs @@ -79,16 +79,16 @@ pub fn restart() { mod pa_impl { use super::*; - // SAFETY: constrains of hbb_common::mem::aligned_u8_vec must be held - unsafe fn align_to_32(data: Vec) -> 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 { From 85a5fefab8b3a29135c488e19a79f43d027576ad Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:18:57 +0800 Subject: [PATCH 071/121] fix(windows): prevent ghost and duplicate tray icons (#15689) (#15690) * docs(agents): require minimally invasive, additive-first patches Codify the review feedback from the tray ghost-icon fix: fixes should add self-contained code around existing lines instead of restructuring them, keep platform-specific logic in src/platform/ with fn-local imports, and leave only thin one-line hooks in shared files. Co-Authored-By: Claude Fable 5 * fix(windows): stop duplicate tray icons from piling up (#15689) `check_process("--tray", ..)` is used to decide whether a tray process needs to be spawned, but it can miss one that is already running: it cannot read the command line of an elevated process from a non-elevated one (the installer spawns the tray elevated), and wmic, used by 32-bit builds since #11638, is gone from newer Windows 11. `connection.rs` runs that check once per incoming connection, so every miss added another tray icon and they kept piling up, which is the same blind spot behind #6692. Hold a named mutex in the session namespace as the authoritative single instance guard, so a redundant tray process exits before creating an icon. `ERROR_ACCESS_DENIED` also counts as "already running", since it means the mutex belongs to a tray we may not touch. Also remove the icon before the tray menu's "Stop service" calls uninstall_service(): on success it ends the process with std::process::exit, which skips the destructor that would call Shell_NotifyIcon(NIM_DELETE), so every click left a ghost icon behind. The icon is shown again if stopping the service failed or was cancelled. Ghost icons from the taskkill in the install/update/service flows are left alone here. Co-Authored-By: Claude Fable 5 * docs(windows): note that update_me's pid lookup can silently find nothing The pids are matched by command line, which comes back empty for a 32-bit build reading 64-bit processes (hence the `wmic` fallback of #11638, and `wmic` is no longer installed by default since Windows 11 24H2) and for a non-elevated process reading an elevated one. `taskkill` matches by image name and still works, but the session lists are then empty, so the restore guard silently restores nothing and the update leaves the user without a tray icon and main window. Co-Authored-By: Claude Fable 5 * docs(windows): record the confirmed cause of the duplicate tray icons Process Explorer output in #15689 pinned it down: 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, which a medium integrity main window cannot inspect afterwards. Record where the detection fails exactly, so the next reader doesn't have to rediscover that the executable path, not the command line, is what comes back empty. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- AGENTS.md | 6 ++++ src/platform/windows.rs | 70 +++++++++++++++++++++++++++++++++++++++++ src/tray.rs | 26 +++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4f0afd4c178..8f558c95901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,12 @@ * Do not make formatting-only changes. * Keep naming/style consistent with nearby code. +### 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: diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 161365cdcb1..98c4da89ac1 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3162,6 +3162,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()); @@ -3268,6 +3326,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 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(); } From a6708f40e710aba635af96d03d9cc4bd29330580 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 29 Jul 2026 12:31:40 +0800 Subject: [PATCH 072/121] fix https://github.com/rustdesk/rustdesk/issues/15703 --- flutter/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index c6f8aa1c20a..4b98d44ae82 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -1589,7 +1589,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "85789bfe6e4cfaf4ecc00c52857467fdb7f26879" + resolved-ref: "7d9a674818826d7205dcf2d36ebbd2c46df3aaff" url: "https://github.com/rustdesk-org/window_manager" source: git version: "0.3.6" From 12f2de5959fa1fcd36d5a5b0c2fa91657411cc7a Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:09:29 +0800 Subject: [PATCH 073/121] chore(flutter): point window_manager at the post-revert main (#15709) The lock still pinned 7d9a674, the commit rustdesk-org/window_manager#8 reverted. Move it to current main (cf4aef0), which carries the reworked guard for methods called after the toplevel window is destroyed. Edited by hand rather than via pub upgrade: upgrading re-resolved 17 packages, downgrading some and pulling flutter_test and its leak_tracker tree in as new entries, none of which belongs in this change. https://github.com/rustdesk/rustdesk/issues/15703 Co-authored-by: Claude Opus 5 (1M context) --- flutter/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 4b98d44ae82..5c40368a2f7 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -1589,7 +1589,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "7d9a674818826d7205dcf2d36ebbd2c46df3aaff" + resolved-ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc url: "https://github.com/rustdesk-org/window_manager" source: git version: "0.3.6" From 72c052cb9aa756895e1b59d2a7cc69a76d036a37 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Thu, 30 Jul 2026 14:32:07 +1200 Subject: [PATCH 074/121] docs: fix double space in CODE_OF_CONDUCT.md (#15714) Co-authored-by: pi --- docs/CODE_OF_CONDUCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8545b5ed9829b06e25f884adda3b5578694e2903 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Thu, 30 Jul 2026 14:32:23 +1200 Subject: [PATCH 075/121] docs: fix 'gressful' misspelling to 'graceful' in libs/clipboard/README.md (#15713) Co-authored-by: pi --- libs/clipboard/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/clipboard/README.md b/libs/clipboard/README.md index ec08cbf04e3..ee3d31ae29e 100644 --- a/libs/clipboard/README.md +++ b/libs/clipboard/README.md @@ -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. From 3442648afe30bcdd24145b0de2e18f9e762f2c96 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Thu, 30 Jul 2026 14:32:39 +1200 Subject: [PATCH 076/121] docs: fix 'lowlevel' spelling to 'low-level' in libs/clipboard/README.md (#15712) Co-authored-by: pi --- libs/clipboard/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/clipboard/README.md b/libs/clipboard/README.md index ee3d31ae29e..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. From 9aeb54cf33a5ac77bf5c0adb1d4f8309ba78abe5 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:15:05 +0800 Subject: [PATCH 077/121] Fix flutter white window forceredraw (#15717) * fix(flutter/windows): heal the white window left by a resize around the first frame 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. Sciter is unaffected: it repaints synchronously on WM_PAINT and has no such handshake. Upstream has no fix (flutter/flutter#159630, open at P3). Recover with a timer armed at creation and re-armed on WM_SHOWWINDOW (covers windows created hidden and shown much later, e.g. the connection manager): until the first frame arrives, kick the engine - first with the cheap ForceRedraw(), which only helps when no resize is pending (it is gated on resize_status_ == kDone), then by nudging the Flutter child window by 1px and back, which re-enters OnWindowSizeChanged and heals the wedge the same way minimize/restore does. Because the first-frame callback fires on frame generation even when the present is rejected, a resize observed before the first frame forces one final child refresh - in practice nearly every window sees a pre-first-frame WM_SIZE, so this acts as a cheap unconditional guarantee. Giving up after 5s is logged. The remote session windows get the same fix in rustdesk_desktop_multi_window. https://github.com/rustdesk/rustdesk/issues/6756 Co-Authored-By: Claude Fable 5 * chore(flutter): bump desktop_multi_window for the white-window fix Picks up rustdesk-org/rustdesk_desktop_multi_window#33 (340ca43), the session-window side of the FancyZones white-window workaround. Only the resolved-ref of this one dependency is moved; nothing else is upgraded. https://github.com/rustdesk/rustdesk/issues/6756 Co-Authored-By: Claude Fable 5 * fix(flutter/windows): drop a dead guard and log where users can see it Two follow-ups on the force-redraw timer. The resized_before_first_frame_ guard never discriminated. CreateWindow() sends a WM_SIZE before it returns, and WM_NCCREATE has already installed the window pointer by then, so the flag was set during construction - before OnCreate() even arms the timer - and was therefore always true when the first frame arrived. Drop the flag and do the final child refresh unconditionally, which is what the code already did, and say so instead of implying there is an exceptional case. The give-up message went to std::cerr, which lands nowhere on the machines that hit this: main.cpp only attaches a console when the process is started from one or runs under a debugger. Use OutputDebugString so it is actually readable with DebugView in the field. Also note in the comment that the "callback fires on frame generation" premise is not load-bearing - if it only fired on a successful present, the timer would simply keep nudging - so the redundancy is not mistaken for duplication and removed later. Co-Authored-By: Claude Opus 5 (1M context) * chore(flutter): bump desktop_multi_window to pick up the follow-ups Moves the pin from the #33 merge (340ca43) to current master (f8c4fce), which adds #34: the dead resized_before_first_frame_ guard is gone and the give-up message goes to OutputDebugString instead of a stderr nobody sees. Keeps the sub-window fix in step with the runner fix in this branch; without it the two would ship the same logic in two different states. Edited by hand, not via pub upgrade - that re-resolves unrelated packages. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5 --- flutter/pubspec.lock | 2 +- flutter/windows/runner/flutter_window.cpp | 109 ++++++++++++++++++++++ flutter/windows/runner/flutter_window.h | 8 ++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 5c40368a2f7..163b2ab1b03 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -340,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: b47e8385e5a75d38319ad706a64b0ead3108b093 + resolved-ref: f8c4fce53014e21b9b58e9f12382bcd45d984d5d url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.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_ From e63df747156c9db2cb74ae342e119a2a418371ea Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:01:05 +0800 Subject: [PATCH 078/121] fix(linux): make quit_cm actually quit the connection manager (#15718) quit_gui() ends the process on Windows (std::process::exit) and macOS (NSApp terminate), 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 and the assertion inside gtk_main_quit() just fails. quit_cm() is the only caller that relies on quit_gui() to end the process. The main window path in ipc.rs calls std::process::exit(-1) right after it, and the two remaining call sites are in the Sciter UI, which is not compiled for flutter builds. So a connection manager reaching quit_cm() on Linux kept running while no longer serving the `_cm` ipc endpoint, which also stops the server from reusing it, so the next connection spawns one more. NOTE: this is a fallback, not an explanation for the stale processes of #15698: a client merely disconnecting does not reach quit_cm(), the Flutter side closes the window instead. Co-authored-by: Claude Fable 5 --- src/ui_cm_interface.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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(); } From c6c53f094a2705de33a95e3196eb20cbb2f6deb2 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 30 Jul 2026 14:19:05 +0800 Subject: [PATCH 079/121] chore(flutter): bump desktop_multi_window to fix the Windows /WX build The give-up log added in the white-window follow-ups declared a local named message inside MessageHandler, shadowing its UINT message parameter. MSVC C4457 plus /WX failed both Windows nightly jobs. Point the lock at rustdesk_desktop_multi_window#35 which renames it. https://github.com/rustdesk/rustdesk/actions/runs/30512756157 Co-Authored-By: Claude Fable 5 --- flutter/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 163b2ab1b03..cba9ba5eab9 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -340,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: f8c4fce53014e21b9b58e9f12382bcd45d984d5d + resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.0" From 5aeb4cf945ee6f08afd9f4be672873c67dfbcbf8 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 30 Jul 2026 15:28:39 +0800 Subject: [PATCH 080/121] add zstd to reqwest --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d909ec3a208..d2f85f6f999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ 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 From 006b9737e4224b54f30375b5d7eb2fa6ecc618c4 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:40:27 +0800 Subject: [PATCH 081/121] fix(linux): load librustdesk.so relative to the executable (#15719) * fix(linux): load librustdesk.so relative to the executable The runner and the Dart FFI init loaded the core library by bare name, relying on the runner's $ORIGIN/lib RPATH. Repackaged installs (CachyOS repo, AUR) can lose that RPATH, making the app fail to start with "Failed to load librustdesk.so" unless users add the lib directory to ld.so.conf. Resolve lib/librustdesk.so next to the executable first, then fall back to the loader search path. https://github.com/rustdesk/rustdesk/discussions/14407 Co-Authored-By: Claude Fable 5 * fix(linux): harden bundled librustdesk.so resolution Address review: bail out when readlink() may have truncated the executable path, and widen the Dart try block so any failure probing the bundled library falls back to the loader search path. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- flutter/lib/models/native_model.dart | 19 +++++++++++++++- flutter/linux/main.cc | 34 +++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index b57867838c4..e73cbc0cb53 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -25,6 +25,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 +137,7 @@ class PlatformFFI { final dylib = isAndroid ? DynamicLibrary.open('librustdesk.so') : isLinux - ? DynamicLibrary.open('librustdesk.so') + ? _openLinuxCoreLib() : isWindows ? DynamicLibrary.open('librustdesk.dll') : 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; From e0254d997e89377070291350398bb72db1e03f17 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 30 Jul 2026 16:40:56 +0800 Subject: [PATCH 082/121] fix ci --- Cargo.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 78ff9eb463b..91543c30dab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,8 @@ dependencies = [ "compression-core", "flate2", "memchr", + "zstd 0.13.1", + "zstd-safe 7.1.0", ] [[package]] From 807e05ea9a7e298ed2deb438195faaafce19cdd2 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 30 Jul 2026 18:23:48 +0800 Subject: [PATCH 083/121] refact(oidc): login with api domain (#15710) Signed-off-by: fufesou --- src/hbbs_http/account.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 3f824113b17..46f6969ee9e 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -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, "")?; From b19f1ef76f3cbfadc1a95b9d669c272ba7f02ef5 Mon Sep 17 00:00:00 2001 From: Daniel Marschall <28412477+danielmarschall@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:17:05 +0200 Subject: [PATCH 084/121] Add SBOM (Software Bill of Materials) for the EU Cyber Resilience Act (EU CRA) (#15732) * Update flutter-build.yml to generate SBOM * SBOM Generation: Also checkout submodules Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .github/workflows/flutter-build.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 2491789e6a4..63526f95e3b 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -53,6 +53,34 @@ env: SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" jobs: + generate-sbom: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Install Syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Generate SBOM + run: | + syft dir:. \ + -o cyclonedx-json=rustdesk.sbom.json + + - name: Publish Release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + if: env.UPLOAD_ARTIFACT == 'true' + with: + prerelease: true + tag_name: ${{ env.TAG_NAME }} + files: | + rustdesk.sbom.json + generate-bridge: uses: ./.github/workflows/bridge.yml From 6c69faaa1c4fedd22dedd6fd03948ab6fb60665e Mon Sep 17 00:00:00 2001 From: Mr-Update <37781396+Mr-Update@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:47:26 +0200 Subject: [PATCH 085/121] Update de.rs (#15733) --- src/lang/de.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/de.rs b/src/lang/de.rs index 08f4673660e..92e88859169 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay-Verbindung"), ("Secure Connection", "Sichere Verbindung"), ("Insecure Connection", "Unsichere Verbindung"), - ("Continue", ""), + ("Continue", "Weiter"), ("Scale original", "Keine Skalierung"), ("Scale adaptive", "Anpassbare Skalierung"), ("General", "Allgemein"), @@ -772,7 +772,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), + ("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"), ].iter().cloned().collect(); } From a5018a022b78ca410a1569c1dfdc82e755f3a65b Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:55:57 +0800 Subject: [PATCH 086/121] chore(ios): remove unused GoogleService-Info.plist (#15752) Leftover from an abandoned Firebase integration. The file is not referenced anywhere in the repository and is not listed in Runner.xcodeproj, so it was never copied into the app bundle. No Firebase or Google Sign-In pod is present in Podfile/Podfile.lock, nothing calls FirebaseApp.configure(), Info.plist declares no REVERSED_CLIENT_ID URL scheme, and on the Dart side both Firebase.initializeApp() and firebase_analytics stay commented out. Note the values it held were Firebase client configuration (project identifiers and a public OAuth client id), which are public by design and ship inside client binaries -- not secrets. This removes dead weight, it is not a credential rotation. Co-authored-by: Claude Opus 5 (1M context) --- flutter/ios/Runner/GoogleService-Info.plist | 36 --------------------- 1 file changed, 36 deletions(-) delete mode 100644 flutter/ios/Runner/GoogleService-Info.plist 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 From ffe20bb297a1d7966b769a9907d40e34888a0426 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:19:53 +0800 Subject: [PATCH 087/121] Login options error feedback (#15727) * fix(flutter): show error and retry when fetching login options fails The third-party login section of the login dialog was silently hidden whenever /api/login-options could not be fetched (e.g. TLS handshake aborted by a router/ISP scam filter, discussion #15700), leaving users staring at a dialog with no feedback. The pure-Dart HTTP path also had no timeout, so a black-holed connection could hang indefinitely. - let transport errors propagate from queryOidcLoginOptions instead of swallowing them; a non-JSON response still means "no third-party login" so self-hosted servers without this API keep the old behavior - show network_error_tip, a Retry button, and the underlying error in the login dialog so users and supporters can see what failed - bound the Dart HTTP branch with a 15s timeout; the Rust branch keeps its own bounded per-attempt timeouts and is awaited to completion so a retry never races the URL-keyed ASYNC_HTTP_STATUS entry of an abandoned in-flight request Co-Authored-By: Claude Fable 5 * fix(flutter): surface currentUser refresh failures that were only logged Non-transport failures of the token auto-login (/api/currentUser) -- a bad HTTP status, a filter's HTML block page, or an error field in the body -- were only debugPrinted, so the address book / group tabs showed nothing and offered no retry. Reuse the existing networkError channel so netWorkErrorWidget shows the error with its Retry button. Co-Authored-By: Claude Fable 5 * fix(flutter): keep retry row visible with progress while refetching login options Review follow-ups: clicking Retry used to clear the error and hide the row with no pending feedback, which could read as a dead click while the Rust fallback chain runs; keep the row, disable the button, and show the usual LinearProgressIndicator instead. Also raise the Dart HTTP branch timeout to 30s so large web address book pulls on slow links do not newly time out; it still bounds the previously unbounded hang and stays above the Rust side's 12s per-attempt timeout. Co-Authored-By: Claude Fable 5 * chore: update webpki-roots to latest Mozilla root store 0.26.9 -> 0.26.11 (now a forwarding shim over 1.x, used by tungstenite) 1.0.4 -> 1.0.9 (used by reqwest / hyper-rustls / hbb_common) The 0.26.9 line carried its own root snapshot frozen in early 2025, so the websocket TLS path was building against a stale bundle. Co-Authored-By: Claude Fable 5 * ci: weekly workflow to PR webpki-roots root store updates webpki-roots is a transitive dependency, so dependabot's cargo version updates would not cover it. A scheduled job runs cargo update for every webpki-roots instance in each lockfile and opens a PR when the pinned Mozilla root snapshot is behind, keeping root store changes reviewable instead of baking them silently into release builds. Co-Authored-By: Claude Fable 5 * fix(flutter): hide network tip for server-reported currentUser errors Review follow-up: when /api/currentUser fails with an error the server itself reported (an error field in a JSON body, or an unexpected schema), "Please check your network connection" was misleading. Track whether the surfaced error came from a server response and skip the network tip for those; FormatException (a non-JSON body such as a filter's block page) keeps it, since that still indicates a network or middlebox problem. Co-Authored-By: Claude Fable 5 * fix(flutter): close timed-out HTTP clients * fix(flutter): flag server-reported errors at the throw site Review follow-up (CodeRabbit). Classifying by `e is! FormatException` mislabeled ambiguous failures: a middlebox block page returning 200 with valid-but-wrong-shape JSON throws a TypeError from fromJson and was shown without the check-your-network tip, though it is a network artifact. Set networkErrorFromServer only at the one site that is certainly server-reported (an error field in the body); every other failure keeps the network tip plus the raw error text. Co-Authored-By: Claude Fable 5 * ci: serialize webpki-roots update runs, null-delimit lockfile paths Review follow-up (CodeRabbit). A manual dispatch overlapping the weekly cron could have an older run force-push over the newer branch state; queue runs via a concurrency group without cancel-in-progress. Also iterate lockfiles with git ls-files -z so a path with spaces cannot be word-split, and keep the loop failing the step on any cargo error. Co-Authored-By: Claude Fable 5 * fix(flutter): improve login retry feedback Use the theme primary color for the Retry button and hide stale error messages while a retry is in progress. Signed-off-by: fufesou * fix(flutter): surface login option response errors Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Fable 5 Co-authored-by: fufesou --- .github/workflows/update-webpki-roots.yml | 71 +++++++++++++++++++++++ Cargo.lock | 20 +++---- flutter/lib/common.dart | 3 +- flutter/lib/common/widgets/login.dart | 63 ++++++++++++++++---- flutter/lib/models/user_model.dart | 58 +++++++++++------- flutter/lib/utils/http_service.dart | 57 ++++++++++++------ 6 files changed, 211 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/update-webpki-roots.yml 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/Cargo.lock b/Cargo.lock index 91543c30dab..aa88bc7db4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3821,7 +3821,7 @@ dependencies = [ "url", "users 0.11.0", "uuid", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", "webrtc", "whoami", "winapi 0.3.9", @@ -4020,7 +4020,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -7112,7 +7112,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots 1.0.9", ] [[package]] @@ -8826,7 +8826,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tungstenite", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -9140,7 +9140,7 @@ dependencies = [ "sha1", "thiserror 2.0.17", "utf-8", - "webpki-roots 0.26.9", + "webpki-roots 0.26.11", ] [[package]] @@ -9813,18 +9813,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", ] diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index cb3faf16321..94c3c2a72b8 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -4048,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/login.dart b/flutter/lib/common/widgets/login.dart index 31917189582..fa64e0eb51a 100644 --- a/flutter/lib/common/widgets/login.dart +++ b/flutter/lib/common/widgets/login.dart @@ -267,8 +267,7 @@ class _WidgetOPState extends State { 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 +288,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, + ), ), ), ], @@ -468,9 +466,22 @@ Future loginDialog() async { 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.toString(); + } finally { + loginOptionsInProgress.value = false; + } + } + + Future.delayed(Duration.zero, fetchLoginOptions); final res = await gFFI.dialogManager.show((setState, close, context) { username.addListener(() { @@ -574,6 +585,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) + 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, + style: const TextStyle(fontSize: 11, color: Colors.red), + textAlign: TextAlign.center, + ), + ], + ); + } return Offstage( offstage: loginOptions.isEmpty, child: Column( diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index cecb58eaa54..9ebb6f76b84 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,31 @@ class UserModel { return loginResponse; } + /// Throws on network failure so callers can surface the error and offer a + /// retry; returns an empty list when the server has no third-party login. 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/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 { From 2f8822ec7a93e15c21df58f472bcac02e7f86dbe Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Tue, 4 Aug 2026 04:29:31 +0200 Subject: [PATCH 088/121] Update nl.rs (#15754) * Update nl.rs Updates plus a small improvement to the Dutch language file * Update nl.rs Now including fixes for coderabbit reportings * Update nl.rs Three more fixes re. greptile * Update nl.rs typo 'loskoppelenn' fixed as well --- src/lang/nl.rs | 438 ++++++++++++++++++++++++------------------------- 1 file changed, 219 insertions(+), 219 deletions(-) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 2ab9e3baa24..9f3b9f7d37d 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,127 +292,127 @@ 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"), + ("Please install plugins", "Plugins installeren"), ("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"), - ("Continue", ""), + ("Show Toolbar", "Werkbalk weergeven"), + ("Hide Toolbar", "Werkbalk verbergen"), + ("Direct Connection", "Directe verbinding"), + ("Relay Connection", "Relay-verbinding"), + ("Secure Connection", "Beveiligde verbinding"), + ("Insecure Connection", "Onveilige verbinding"), + ("Continue", "Doorgaan"), ("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"), @@ -429,7 +429,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"), @@ -442,33 +442,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."), @@ -482,31 +482,31 @@ 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"), + ("Uninstall", "Verwijderen"), ("Update", "Bijwerken"), - ("Enable", "Activeer"), - ("Disable", "Deactiveer"), + ("Enable", "Activeren"), + ("Disable", "Deactiveren"), ("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"), @@ -514,24 +514,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"), @@ -539,13 +539,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"), @@ -557,19 +557,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"), @@ -591,16 +591,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"), @@ -621,13 +621,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"), @@ -644,56 +644,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."), @@ -710,7 +710,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"), @@ -738,41 +738,41 @@ 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 {}"), - ("conn-e2ee-unavailable-tip", "End-to-endversleuteling kon niet worden geverifieerd.\nHet externe apparaat wordt mogelijk nog ingesteld. Probeer het later opnieuw.\nAls dit blijft gebeuren, is de server mogelijk niet vertrouwd.\nToch doorgaan?"), - ("ID whitelisting", "ID Witte Lijst"), - ("Use ID whitelisting", "Gebruik een witte lijst van ID's"), - ("id_whitelist_tip", "Alleen ID's op de witte lijst krijgen toegang tot mijn toestel"), - ("id_whitelist_wildcard_tip", "Jokertekens worden ondersteund: '*' komt overeen met een willekeurig aantal tekens, '?' met precies één teken"), + ("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", "Je ID is geblokkeerd door de andere partij"), - ("Your ip is blocked by the peer", "Je IP-adres is geblokkeerd door de andere partij"), - ("id_whitelist_caveat_tip", "Het ID wordt gemeld door de verbindende client. De witte lijst vermindert blootstelling en vervangt het wachtwoord of 2FA niet"), - ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijvoorbeeld 192.168.1.0/24"), + ("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"), ].iter().cloned().collect(); } From d752823b8c2e0f5153df18de604911234eb39fc8 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:08:59 +0800 Subject: [PATCH 089/121] swtich_code for hbbs (#15615) * swtich_code for hbbs to bypass ACL * improve register_switch_grant: skip public server, log at error level Also document why registration is fire-and-forget with no retry: the peer connects within seconds, so a late retry would land after its punch request was already rejected; a failed switch is recovered by the user triggering it again, which registers a fresh grant. Co-Authored-By: Claude Fable 5 * add timestamp Signed-off-by: 21pages * fix(switch-sides): handle grant registration clock skew - retry registration once with the server-provided timestamp - require an explicit accepted response from hbbs - report malformed or incomplete responses Signed-off-by: 21pages * fix(switch-sides): register grants with code verifiers - send a derived verifier instead of the raw switch code - use detached signatures for grant registration - add verifier and signed-message tests Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: 21pages Co-authored-by: Claude Fable 5 --- src/client.rs | 22 +++++-- src/hbbs_http/sync.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ src/ipc.rs | 1 + 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/client.rs b/src/client.rs index f711c227c72..6f234786876 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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?; @@ -3754,6 +3758,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) { 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 4498ceb5fc5..188c2e4677e 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -978,6 +978,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())) From e6dd925ab0fcb7b3e4061927cf2b5faa317fe99f Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:18:14 +0800 Subject: [PATCH 090/121] fix(android): close outgoing sessions when the task is swiped away (#15753) * fix(android): close outgoing sessions when the task is swiped away Swiping RustDesk away from recents destroys the UI but does not necessarily end the process: when MainService is running (screen share enabled, or started at boot) the process survives, and with it the native io_loop of any active outgoing session. That orphaned io_loop keeps echoing TestDelay (client.rs handle_test_delay runs entirely on the network thread, no UI involved), which keeps refreshing last_recv_time on the controlled side. Its 30s inactivity timeout in server/connection.rs therefore never fires, so the remote session stays established with no UI left to close it, and the peer cannot be reconnected to. Close client sessions from Service.onTaskRemoved, which fires only on explicit task removal -- not on Home or backgrounding, so ordinary backgrounding is unaffected. The service itself keeps running, so incoming connections and the device staying reachable are unchanged. This complements 152c5c71b, which covered the route-pop path via dispose(); dispose() does not run when the task is removed. Co-Authored-By: Claude Opus 5 (1M context) * fix(android): also close sessions on activity destroy Review follow-up. onTaskRemoved only reaches MainService, but the accessibility InputService keeps the process alive on its own: a user with input control enabled and screen sharing off has a surviving process after a swipe while MainService is not running, so the callback never fires and the session still outlives its UI. onTaskRemoved cannot cover that -- InputService is bound by the system, not started, so the callback is not delivered there. Close from MainActivity.onDestroy() as well, which runs while the process is still alive regardless of which service keeps it up. Guarded on isFinishing so a destroy for recreation (configuration change, "don't keep activities") does not tear down a live session. Both paths are idempotent. Also drop the now-wrong "on task removed" wording from the Rust log, which has two distinct callers. Co-Authored-By: Claude Opus 5 (1M context) * fix(android): release held keys before draining the session map close_all_sessions drained SESSIONS first, then called release_remote_keys. The release path sends through get_cur_session(), which resolves against SESSIONS, so every generated key-up was dropped after take_remote_keys() had already cleared TO_RELEASE: a key held as the task is removed stays down on the controlled side until its own timeout, with the state lost locally. Release first, while a session is still registered. It is a no-op when no key is held, so the previous is_empty() guard is not needed. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../com/carriez/flutter_hbb/MainActivity.kt | 10 +++++ .../com/carriez/flutter_hbb/MainService.kt | 10 +++++ flutter/android/app/src/main/kotlin/ffi.kt | 1 + src/flutter.rs | 39 +++++++++++++++++++ src/flutter_ffi.rs | 10 +++++ 5 files changed, 70 insertions(+) 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 5561b8814da..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) } 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 7bb16a00ad6..b03b63844b4 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 @@ -254,6 +254,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 e3c9d9830d4..89e3dc04671 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/src/flutter.rs b/src/flutter.rs index a07d7c5987b..f6e3d3edd91 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -2131,6 +2131,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 0777443cf3b..9b73c4cd4a1 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -3128,6 +3128,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, From a84bad4639689e37d4278544aeeb0d89745dd950 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 4 Aug 2026 12:35:04 +0800 Subject: [PATCH 091/121] refact(oidc): manually open the browser (#15706) * refact(oidc): manually open the browser Signed-off-by: fufesou * refact(oidc): allow copying OIDC authentication links Signed-off-by: fufesou * Remove unused translation in ko.rs Signed-off-by: fufesou * refact(oidc): better hint on browser didn't open Signed-off-by: fufesou * refact(oidc): login handle exception Signed-off-by: fufesou * refact(oidc): remove unused translations Signed-off-by: fufesou * refact(oidc): login handle error Signed-off-by: fufesou * refact(oidc): login in flight Signed-off-by: fufesou * refact(translation): move "Continue" to the end of template.rs Signed-off-by: fufesou * refact(oidc): var rename Signed-off-by: fufesou * refact(oidc): remove useless "open sign-in page" Signed-off-by: fufesou * Remove unecessary translation contents Signed-off-by: fufesou * refact(oidc): better way to show&expand the url Signed-off-by: fufesou * refact(oidc): better login ui Signed-off-by: fufesou * fix(oidc): discard stale auth results after cancellation Signed-off-by: fufesou * fix(oidc): handle auth status query failures safely Signed-off-by: fufesou * fix(oidc): prevent concurrent login operations - reuse the active login dialog and block duplicate password submissions - cancel only active OIDC operations when closing the dialog - preserve authentication state until failure cancellation succeeds Signed-off-by: fufesou * fix(oidc): refine login options error feedback Preserve typed errors to hide the network tip for HTTP failures and clarify the login-options API contract. Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/login.dart | 441 ++++++++++++++---- .../lib/common/widgets/oidc_auth_status.dart | 157 +++++++ flutter/lib/models/user_model.dart | 5 +- src/hbbs_http/account.rs | 131 ++++-- src/lang/ar.rs | 3 +- src/lang/be.rs | 3 +- src/lang/bg.rs | 3 +- src/lang/ca.rs | 3 +- src/lang/cn.rs | 3 +- src/lang/cs.rs | 3 +- src/lang/da.rs | 3 +- src/lang/de.rs | 3 +- src/lang/el.rs | 3 +- src/lang/eo.rs | 3 +- src/lang/es.rs | 3 +- src/lang/et.rs | 3 +- src/lang/eu.rs | 3 +- src/lang/fa.rs | 3 +- src/lang/fi.rs | 3 +- src/lang/fr.rs | 3 +- src/lang/ge.rs | 3 +- src/lang/gu.rs | 3 +- src/lang/he.rs | 3 +- src/lang/hi.rs | 3 +- src/lang/hr.rs | 3 +- src/lang/hu.rs | 3 +- src/lang/id.rs | 3 +- src/lang/it.rs | 3 +- src/lang/ja.rs | 3 +- src/lang/ko.rs | 2 + src/lang/kz.rs | 3 +- src/lang/lt.rs | 3 +- src/lang/lv.rs | 3 +- src/lang/ml.rs | 3 +- src/lang/nb.rs | 3 +- src/lang/nl.rs | 3 +- src/lang/pl.rs | 3 +- src/lang/pt_PT.rs | 3 +- src/lang/ptbr.rs | 3 +- src/lang/ro.rs | 3 +- src/lang/ru.rs | 3 +- src/lang/sc.rs | 3 +- src/lang/sk.rs | 3 +- src/lang/sl.rs | 3 +- src/lang/sq.rs | 3 +- src/lang/sr.rs | 3 +- src/lang/sv.rs | 3 +- src/lang/ta.rs | 3 +- src/lang/template.rs | 3 +- src/lang/th.rs | 3 +- src/lang/tr.rs | 3 +- src/lang/tw.rs | 3 +- src/lang/uk.rs | 3 +- src/lang/vi.rs | 3 +- 54 files changed, 700 insertions(+), 183 deletions(-) create mode 100644 flutter/lib/common/widgets/oidc_auth_status.dart diff --git a/flutter/lib/common/widgets/login.dart b/flutter/lib/common/widgets/login.dart index fa64e0eb51a..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,11 +515,20 @@ 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) @@ -304,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), - ), - ), - ), - ), - ), ], ); } @@ -341,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 @@ -357,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget { config: op, curOP: curOP, cbLogin: cbLogin, + startAuth: startAuth, + cancelAuth: cancelAuth, + canStartAuth: canStartAuth, ), const Divider( indent: 5, @@ -434,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, )), ), ])), @@ -450,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(); @@ -461,12 +729,13 @@ 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; - final loginOptionsError = Rxn(); + final loginOptionsError = Rxn(); final loginOptionsInProgress = false.obs; fetchLoginOptions() async { loginOptionsInProgress.value = true; @@ -475,7 +744,7 @@ Future loginDialog() async { loginOptionsError.value = null; } catch (e) { debugPrint("queryOidcLoginOptions failed: $e"); - loginOptionsError.value = e.toString(); + loginOptionsError.value = e; } finally { loginOptionsInProgress.value = false; } @@ -555,6 +824,9 @@ Future loginDialog() async { } onLogin() async { + if (curOP.value.isNotEmpty || isInProgress) { + return; + } // validate if (username.text.isEmpty) { setState(() => usernameMsg = translate('Username missed')); @@ -593,7 +865,7 @@ Future loginDialog() async { const SizedBox(height: 8.0), // NOT use Offstage to wrap LinearProgressIndicator if (inProgress) const LinearProgressIndicator(), - if (!inProgress) + if (!inProgress && error is! RequestException) Text( translate('network_error_tip'), style: const TextStyle(fontSize: 12), @@ -608,7 +880,7 @@ Future loginDialog() async { ), if (!inProgress) SelectableText( - error, + error.toString(), style: const TextStyle(fontSize: 11, color: Colors.red), textAlign: TextAlign.center, ), @@ -635,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 { @@ -716,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/models/user_model.dart b/flutter/lib/models/user_model.dart index 9ebb6f76b84..405a9faddc9 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -234,8 +234,9 @@ class UserModel { return loginResponse; } - /// Throws on network failure so callers can surface the error and offer a - /// retry; returns an empty list when the server has no third-party login. + /// 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 { final url = await bind.mainGetApiServer(); if (url.trim().isEmpty) return []; diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 46f6969ee9e..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), } @@ -192,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) } @@ -205,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; @@ -220,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( @@ -281,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; } } @@ -310,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) { @@ -339,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(); }); } @@ -358,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/lang/ar.rs b/src/lang/ar.rs index b0c695b812f..2189648d94d 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "اتصال الوسيط"), ("Secure Connection", "اتصال آمن"), ("Insecure Connection", "اتصال غير آمن"), - ("Continue", ""), ("Scale original", "المقياس الأصلي"), ("Scale adaptive", "مقياس التكيف"), ("General", "عام"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 1c726b71ad0..ac302f3af48 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Рэтрансляванае падключэнне"), ("Secure Connection", "Бяспечнае падключэнне"), ("Insecure Connection", "Нябяспечнае падключэнне"), - ("Continue", ""), ("Scale original", "Арыгінальны маштаб"), ("Scale adaptive", "Адаптыўны маштаб"), ("General", "Агульныя"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 43380a92bb5..c339270c0a8 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Релейна връзка"), ("Secure Connection", "Сигурна връзка"), ("Insecure Connection", "Несигурна връзка"), - ("Continue", ""), ("Scale original", "Оригинален мащаб"), ("Scale adaptive", "Приспособимо мащабиране"), ("General", "Основен"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 001d12b7f17..d3b0ae7e0cc 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connexió amb repetidor"), ("Secure Connection", "Connexió segura"), ("Insecure Connection", "Connexió no segura"), - ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptativa"), ("General", "General"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 dff0a2e2d25..7423cceb331 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中继连接"), ("Secure Connection", "安全连接"), ("Insecure Connection", "非安全连接"), - ("Continue", "继续"), ("Scale original", "原始尺寸"), ("Scale adaptive", "适应窗口"), ("General", "常规"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 420913038f2..abd4e60aa79 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Připojení předávací server"), ("Secure Connection", "Zabezpečené připojení"), ("Insecure Connection", "Nezabezpečené připojení"), - ("Continue", ""), ("Scale original", "Originální měřítko"), ("Scale adaptive", "Adaptivní měřítko"), ("General", "Obecné"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 f7579b22b60..0ecab9098c1 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Viderestillingsforbindelse"), ("Secure Connection", "Sikker forbindelse"), ("Insecure Connection", "Usikker forbindelse"), - ("Continue", ""), ("Scale original", "Original skalering"), ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 92e88859169..d71dfa6ce33 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay-Verbindung"), ("Secure Connection", "Sichere Verbindung"), ("Insecure Connection", "Unsichere Verbindung"), - ("Continue", "Weiter"), ("Scale original", "Keine Skalierung"), ("Scale adaptive", "Anpassbare Skalierung"), ("General", "Allgemein"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 e3fc945648a..5ba349a9c44 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Αναμεταδιδόμενη σύνδεση"), ("Secure Connection", "Ασφαλής σύνδεση"), ("Insecure Connection", "Μη ασφαλής σύνδεση"), - ("Continue", ""), ("Scale original", "Κλιμάκωση πρωτότυπου"), ("Scale adaptive", "Προσαρμοσμένη κλίμακα"), ("General", "Γενικά"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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/eo.rs b/src/lang/eo.rs index 7af41cd3f6c..e6cc0cae523 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relajsa Konekto"), ("Secure Connection", "Sekura Konekto"), ("Insecure Connection", "Nesekura Konekto"), - ("Continue", ""), ("Scale original", "Skalo originalo"), ("Scale adaptive", "Skalo adapta"), ("General", "Ĝenerala"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 1e592934a3e..2e7ace9cf02 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexión Relay"), ("Secure Connection", "Conexión segura"), ("Insecure Connection", "Conexión insegura"), - ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptativa"), ("General", "General"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 9eccaa6c2a7..238c84c88e4 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Releeühendus"), ("Secure Connection", "Turvaline ühendus"), ("Insecure Connection", "Ebaturvaline ühendus"), - ("Continue", ""), ("Scale original", "Originaalskaala"), ("Scale adaptive", "Kohanduv skaala"), ("General", "Üldine"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 a3b752a502b..3fd38eb553e 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Konexio igorria"), ("Secure Connection", "Konexio segurua"), ("Insecure Connection", "Konexio ez-segurua"), - ("Continue", ""), ("Scale original", "Jatorrizko eskala"), ("Scale adaptive", "Eskala moldagarria"), ("General", "Orokorra"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 3cdbcb3bc32..1e4039be7d0 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay ارتباط"), ("Secure Connection", "ارتباط امن"), ("Insecure Connection", "ارتباط غیر امن"), - ("Continue", ""), ("Scale original", "مقیاس اصلی"), ("Scale adaptive", "مقیاس تطبیقی"), ("General", "عمومی"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 0edd04d4597..2a21ba04964 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Välitetty yhteys"), ("Secure Connection", "Suojattu yhteys"), ("Insecure Connection", "Suojaamaton yhteys"), - ("Continue", ""), ("Scale original", "Skaalaa alkuperäinen"), ("Scale adaptive", "Mukautuva skaalaus"), ("General", "Yleiset"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 aa822413fab..8359587a20c 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connexion via relais"), ("Secure Connection", "Connexion sécurisée"), ("Insecure Connection", "Connexion non sécurisée"), - ("Continue", ""), ("Scale original", "Échelle originale"), ("Scale adaptive", "Échelle adaptative"), ("General", "Général"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 b3fe30dd9b3..97c3e9171eb 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "რეტრანსლირებული კავშირი"), ("Secure Connection", "უსაფრთხო კავშირი"), ("Insecure Connection", "არაუსაფრთხო კავშირი"), - ("Continue", ""), ("Scale original", "ორიგინალური მასშტაბი"), ("Scale adaptive", "ადაპტირებადი მასშტაბი"), ("General", "ზოგადი"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 a150047d72b..c9c2c9177f0 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "રિલે કનેક્શન"), ("Secure Connection", "સુરક્ષિત કનેક્શન"), ("Insecure Connection", "અસુરક્ષિત કનેક્શન"), - ("Continue", ""), ("Scale original", "મૂળ સ્કેલ"), ("Scale adaptive", "એડેપ્ટિવ સ્કેલ"), ("General", "સામાન્ય"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 dfe37733d48..3ea0d762615 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "חיבור באמצעות ממסר"), ("Secure Connection", "חיבור מאובטח"), ("Insecure Connection", "חיבור לא מאובטח"), - ("Continue", ""), ("Scale original", "קנה מידה מקורי"), ("Scale adaptive", "קנה מידה מותאם"), ("General", "כללי"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 a146053dff5..e3851a0d85b 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "रिले कनेक्शन"), ("Secure Connection", "सुरक्षित कनेक्शन"), ("Insecure Connection", "असुरक्षित कनेक्शन"), - ("Continue", ""), ("Scale original", "मूल पैमाना"), ("Scale adaptive", "अनुकूली पैमाना"), ("General", "सामान्य"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 220bafac5c9..ee894b0e7d8 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredna veza"), ("Secure Connection", "Sigurna veza"), ("Insecure Connection", "Nesigurna veza"), - ("Continue", ""), ("Scale original", "Skaliraj izvornik"), ("Scale adaptive", "Prilagođeno skaliranje"), ("General", "Općenito"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 733598c3fdd..14a85f1f7d8 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Kapcsolódás továbbító-kiszolgálón keresztül"), ("Secure Connection", "Biztonságos kapcsolat"), ("Insecure Connection", "Nem biztonságos kapcsolat"), - ("Continue", ""), ("Scale original", "Eredeti méretarány"), ("Scale adaptive", "Adaptív méretarány"), ("General", "Általános"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 0482df42584..7ba387e485a 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Koneksi Relay"), ("Secure Connection", "Koneksi aman"), ("Insecure Connection", "Koneksi Tidak Aman"), - ("Continue", ""), ("Scale original", "Skala asli"), ("Scale adaptive", "Skala adaptif"), ("General", "Umum"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 b002381ef50..1297972dffb 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connessione relay"), ("Secure Connection", "Connessione sicura"), ("Insecure Connection", "Connessione non sicura"), - ("Continue", "Continua"), ("Scale original", "Scala originale"), ("Scale adaptive", "Scala adattiva"), ("General", "Generale"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 9494d19f36f..ba6e6cb0956 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中継接続"), ("Secure Connection", "安全な接続"), ("Insecure Connection", "安全でない接続"), - ("Continue", ""), ("Scale original", "オリジナルのサイズ"), ("Scale adaptive", "ウィンドウに合わせる"), ("General", "一般"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 186476a3ced..f60af542b20 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -773,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 419d80de25d..fc59efde3cf 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Релай Қосылым"), ("Secure Connection", "Қауіпсіз Қосылым"), ("Insecure Connection", "Қатерлі Қосылым"), - ("Continue", ""), ("Scale original", "Scale original"), ("Scale adaptive", "Scale adaptive"), ("General", "Жалпы"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 012ec13162b..3589a2fb31b 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Tarpinė jungtis"), ("Secure Connection", "Saugus ryšys"), ("Insecure Connection", "Nesaugus ryšys"), - ("Continue", ""), ("Scale original", "Pakeisti originalų mastelį"), ("Scale adaptive", "Pritaikomas mastelis"), ("General", "Bendra"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 ee901974bf0..d4101d6dbf2 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Releja savienojums"), ("Secure Connection", "Drošs savienojums"), ("Insecure Connection", "Nedrošs savienojums"), - ("Continue", ""), ("Scale original", "Mērogs oriģināls"), ("Scale adaptive", "Mērogs adaptīvs"), ("General", "Vispārīgi"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 b6faa4655fa..d93760b508a 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "റിലേ കണക്ഷൻ"), ("Secure Connection", "സുരക്ഷിതമായ കണക്ഷൻ"), ("Insecure Connection", "സുരക്ഷിതമല്ലാത്ത കണക്ഷൻ"), - ("Continue", ""), ("Scale original", "ഒറിജിനൽ വലിപ്പം"), ("Scale adaptive", "അഡാപ്റ്റീവ് വലിപ്പം"), ("General", "പൊതുവായവ"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 b15f1c59fb3..3cc71a96b2d 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Viderekoblet tilkobling"), ("Secure Connection", "Sikker tilkobling"), ("Insecure Connection", "Usikker tilkobling"), - ("Continue", ""), ("Scale original", "Original skalering"), ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 9f3b9f7d37d..769371fd820 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relay-verbinding"), ("Secure Connection", "Beveiligde verbinding"), ("Insecure Connection", "Onveilige verbinding"), - ("Continue", "Doorgaan"), ("Scale original", "Oorspronkelijk formaat"), ("Scale adaptive", "Automatisch schalen"), ("General", "Algemeen"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 8971a31b611..df5c53439ec 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Połączenie przez bramkę"), ("Secure Connection", "Połączenie szyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"), - ("Continue", "Kontynuuj"), ("Scale original", "Skalowanie oryginalne"), ("Scale adaptive", "Dopasuj do wyświetlacza"), ("General", "Ogólne"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 e730efcb626..79420e73be1 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexão de relé"), ("Secure Connection", "Conexão segura"), ("Insecure Connection", "Conexão insegura"), - ("Continue", ""), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptável"), ("General", "Geral"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 07d72b26846..8d44d6140c0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexão via Relay"), ("Secure Connection", "Conexão Segura"), ("Insecure Connection", "Conexão Insegura"), - ("Continue", "Continuar"), ("Scale original", "Escala original"), ("Scale adaptive", "Escala adaptada"), ("General", "Geral"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index c703af64820..4499df1bdfd 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Conexiune prin retransmisie"), ("Secure Connection", "Conexiune securizată"), ("Insecure Connection", "Conexiune nesecurizată"), - ("Continue", ""), ("Scale original", "Dimensiune originală"), ("Scale adaptive", "Scalare automată"), ("General", "General"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 b808b5cd3eb..459549f97ea 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Ретранслируемое подключение"), ("Secure Connection", "Безопасное подключение"), ("Insecure Connection", "Небезопасное подключение"), - ("Continue", ""), ("Scale original", "Оригинальный масштаб"), ("Scale adaptive", "Адаптивный масштаб"), ("General", "Общие"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 cbe9103d1f5..1ccfcf7dc78 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Connessione tramudada (relay)"), ("Secure Connection", "Connessione segura"), ("Insecure Connection", "Connessione non segura"), - ("Continue", ""), ("Scale original", "Iscala originale"), ("Scale adaptive", "Iscala adativa"), ("General", "Generale"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 d67c7b86639..3d499311590 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Reléové pripojenie"), ("Secure Connection", "Zabezpečené pripojenie"), ("Insecure Connection", "Nezabezpečené pripojenie"), - ("Continue", ""), ("Scale original", "Pôvodná mierka"), ("Scale adaptive", "Prispôsobivá mierka"), ("General", "Všeobecné"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 index 6bcea909718..10fc5d909c9 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredovana povezava"), ("Secure Connection", "Zavarovana povezava"), ("Insecure Connection", "Nezavarovana povezava"), - ("Continue", ""), ("Scale original", "Originalna velikost"), ("Scale adaptive", "Prilagojena velikost"), ("General", "Splošno"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 33024889062..91f5d4c7a7a 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Lidhja rele"), ("Secure Connection", "Lidhje e sigurt"), ("Insecure Connection", "Lidhje e pasigurt"), - ("Continue", ""), ("Scale original", "Shkalla origjinale"), ("Scale adaptive", " E përsjhtatshme në shkallë"), ("General", "Gjeneral"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 43eb13b90b9..b79eccf5b48 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Posredna konekcija"), ("Secure Connection", "Bezbedna konekcija"), ("Insecure Connection", "Nebezbedna konekcija"), - ("Continue", ""), ("Scale original", "Skaliraj original"), ("Scale adaptive", "Adaptivno skaliranje"), ("General", "Uopšteno"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 d3b04793f8e..79dd316cdd7 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relayanslutning"), ("Secure Connection", "Säker anslutning"), ("Insecure Connection", "Osäker anslutning"), - ("Continue", ""), ("Scale original", "Skala orginal"), ("Scale adaptive", "Skala adaptivt"), ("General", "Generellt"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 3b17828956b..376af972e02 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "ரிலே இணைப்பு"), ("Secure Connection", "பாதுகாப்பான இணைப்பு"), ("Insecure Connection", "பாதுகாப்பற்ற இணைப்பு"), - ("Continue", ""), ("Scale original", "அசல் அளவு"), ("Scale adaptive", "தகவமைப்பு அளவு"), ("General", "பொது"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 24e3a5062ae..f16cf1ebc59 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", ""), ("Secure Connection", ""), ("Insecure Connection", ""), - ("Continue", ""), ("Scale original", ""), ("Scale adaptive", ""), ("General", ""), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 17a050e8449..bd87cf5a70c 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "การเชื่อมต่อแบบ Relay "), ("Secure Connection", "การเชื่อมต่อที่ปลอดภัย"), ("Insecure Connection", "การเชื่อมต่อที่ไม่ปลอดภัย"), - ("Continue", ""), ("Scale original", "ขนาดเดิม"), ("Scale adaptive", "ขนาดยืดหยุ่น"), ("General", "ทั่วไป"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 955ec967303..2925ce79247 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Aktarmalı Bağlantı"), ("Secure Connection", "Güvenli Bağlantı"), ("Insecure Connection", "Güvenli Olmayan Bağlantı"), - ("Continue", ""), ("Scale original", "Orijinal ölçekte"), ("Scale adaptive", "Uyarlanabilir ölçekte"), ("General", "Genel"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 71b853e99c2..0401d80b71c 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中繼連線"), ("Secure Connection", "安全連線"), ("Insecure Connection", "非安全連線"), - ("Continue", ""), ("Scale original", "原始尺寸"), ("Scale adaptive", "適應視窗"), ("General", "一般"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 5de8a557251..7e55426d185 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Ретрансльоване підключення"), ("Secure Connection", "Безпечне підключення"), ("Insecure Connection", "Небезпечне підключення"), - ("Continue", ""), ("Scale original", "Оригінальний масштаб"), ("Scale adaptive", "Адаптивний масштаб"), ("General", "Загальні"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 d32c7ff2e59..af358831e76 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Kết nối chuyển tiếp"), ("Secure Connection", "Kết nối bảo mật"), ("Insecure Connection", "Kết nối không bảo mật"), - ("Continue", ""), ("Scale original", "Tỷ lệ gốc"), ("Scale adaptive", "Tỷ lệ thích ứng"), ("General", "Chung"), @@ -774,5 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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(); } From 4389687d9d08b8957675b923b8a1564fc57e32e4 Mon Sep 17 00:00:00 2001 From: xPrimeTime <101987372+xPrimeTime@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:46:09 +0100 Subject: [PATCH 092/121] fix(wayland): subscribe to portal Response before making the request (#15726) `request_remote_desktop` and its response handlers call the portal method first and only then subscribe to the resulting Request's `Response` signal, using the object path returned by the call. The comment above `create_session` already describes why that is wrong: > To avoid a race condition 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. The code then does the opposite of what the comment says. When the portal emits `Response` before our match rule is installed, the signal is dropped and the flow stalls: `request_remote_desktop` spins its 3-minute wait loop and gives up, so the user sees the screen picker again (or a failure) even when a valid restore token would have restored the session silently. Build the request path from our unique bus name plus the `handle_token` we pass in the call arguments, per the Request documentation, and subscribe before calling. Applied to all five portal calls: CreateSession, SelectSources (both the ScreenCast and post-SelectDevices paths), SelectDevices, and Start. The `handle_token` values are unchanged; they are now named locals so the path and the argument cannot drift apart. Co-authored-by: Claude Opus 5 --- libs/scrap/src/wayland/pipewire.rs | 77 +++++++++++++++++++----------- 1 file changed, 48 insertions(+), 29 deletions(-) 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(()) } From 6f1eb164d616e0e2bfbcf8c6b7c8083b09d7ed06 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 4 Aug 2026 14:11:27 +0800 Subject: [PATCH 093/121] fix(clipboard): validate files (#15693) * fix(clipboard): validate files Signed-off-by: fufesou * fix(clipboard): address file validation review feedback - remove unreachable empty-prefix test assertions - name the shared COM/LPT prefix length - document non-atomic path validation behavior Signed-off-by: fufesou * update hbb_common Signed-off-by: fufesou * fix(clipboard): reject traversal in file descriptors - reuse parser validation for outgoing descriptor names - propagate descriptor serialization errors - add regression coverage for parent path components Signed-off-by: fufesou * fix: clipboard, validate file name length Signed-off-by: fufesou * fix: clipboard, comments Signed-off-by: fufesou * fix(clipboard): support multi-root file selections Use each top-level path's parent as its relative root so file descriptors remain safe and relative across different directories. Add regression coverage for multi-root selections. Signed-off-by: fufesou * fix(clipboard): unix, select multiple items Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/clipboard/src/platform/unix/filetype.rs | 119 ++++++++- .../clipboard/src/platform/unix/local_file.rs | 200 +++++++++++----- .../src/platform/unix/macos/paste_task.rs | 225 +++++++++++++++--- libs/clipboard/src/platform/unix/mod.rs | 4 + .../clipboard/src/platform/unix/serv_files.rs | 9 +- libs/clipboard/src/platform/windows.rs | 76 ++++++ libs/clipboard/src/windows/wf_cliprdr.c | 120 ++++++++++ 7 files changed, 641 insertions(+), 112 deletions(-) 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 b535b2ec7e5..d918ee1db70 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -49,6 +50,9 @@ #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) +#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"<>:\"|?*"; /* 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) @@ -69,6 +73,119 @@ 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; @@ -909,6 +1026,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) From 3cf32e70666006e0273938bf64c6e6e67d3bb82f Mon Sep 17 00:00:00 2001 From: Fadouse Date: Tue, 4 Aug 2026 14:18:41 +0800 Subject: [PATCH 094/121] fix(wayland): scale portal pointer coordinates on niri (#15683) * fix(wayland): scale portal pointer coordinates on niri * perf(wayland): cache portal scaling desktop check --- src/server/rdp_input.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 From 402ed07b0ce6b815ed9818d4c9701599e8976e30 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 4 Aug 2026 14:29:04 +0800 Subject: [PATCH 095/121] fix: Harden Windows installer temp command scripts (#15634) * fix: Harden Windows installer temp command scripts Signed-off-by: fufesou * fix: restore stop-service after install preparation failure Signed-off-by: fufesou * fix(windows): preserve special characters in installer paths Handle carets and exclamation marks safely across cmd.exe parsing stages. Add coverage for special-character paths in the elevated installer handoff. Signed-off-by: fufesou * fix: installer, validate app name Signed-off-by: fufesou * update tests Signed-off-by: fufesou * Simple refactor Signed-off-by: fufesou * Simple refactor Signed-off-by: fufesou --------- Signed-off-by: fufesou --- Cargo.lock | 13 - Cargo.toml | 5 +- src/platform/windows.rs | 292 ++++++++++----------- src/platform/windows/installer_handoff.rs | 288 +++++++++++++++++++++ src/platform/windows/installer_shell.rs | 300 ++++++++++++++++++++++ 5 files changed, 726 insertions(+), 172 deletions(-) create mode 100644 src/platform/windows/installer_handoff.rs create mode 100644 src/platform/windows/installer_shell.rs diff --git a/Cargo.lock b/Cargo.lock index aa88bc7db4a..93e1a683712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7212,18 +7212,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" @@ -7342,7 +7330,6 @@ dependencies = [ "reqwest", "ringbuf", "rubato", - "runas", "rust-pulsectl", "samplerate", "sciter-rs", diff --git a/Cargo.toml b/Cargo.toml index d2f85f6f999..a7b2aca7743 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,14 +124,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" @@ -140,7 +144,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" diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 98c4da89ac1..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 { @@ -2288,7 +2246,7 @@ pub fn create_shortcut(id: &str) -> ResultType<()> { // 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\") @@ -2302,7 +2260,6 @@ Set oLink = oWS.CreateShortcut(sLinkFile) oLink.Save " ), - "vbs", "connect_shortcut", )? .to_str() @@ -3245,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); @@ -3728,39 +3708,13 @@ pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { 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} @@ -3770,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(""), ) } @@ -3784,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} @@ -4681,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""#)); + } +} From ef3a57580faabe033edcdfbb915f8953c431034e Mon Sep 17 00:00:00 2001 From: Alex Rijckaert Date: Wed, 5 Aug 2026 10:51:07 +0200 Subject: [PATCH 096/121] Update Dutch translation (#15767) * Update Dutch translation * Update src/lang/nl.rs Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/lang/nl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 769371fd820..61a5306c9ee 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -774,6 +774,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("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(); } From 7eb915011626f99fa48b35a6fd45aab6f9e2fa82 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:57:09 +0800 Subject: [PATCH 097/121] Audit retry nonce (#15759) * fix: retry audit posts and add per-record nonce A single post_request attempt meant any transient failure (timeout, DNS, connection reset) silently dropped the audit record. Retry up to 3 times with backoff and log at error level when a record is finally dropped. Retries (and the existing TCP-proxy fallback) can deliver the same record twice; attach a per-record nonce so the api server can dedup. Co-Authored-By: Claude Fable 5 * fix: fail audit posts on http error status post_request discards the status code, so a 5xx from a reverse proxy (e.g. nginx answering 502 while hbbs restarts) or any 4xx rejection was treated as success and the audit record silently dropped without a log line. Add post_request_with_status (same semantics and TCP-proxy fallback as post_request, status preserved; existing callers untouched) and use it for audit posts: 2xx succeeds, transport errors and 5xx retry, 4xx fails immediately since retrying a deterministic rejection cannot help. Co-Authored-By: Claude Fable 5 * fix: report audit posts rejected with 200 error body hbbs maps handler failures (e.g. a database write error) to HTTP 200 with an {"error": ...} body (WebError::ServerError), so the client treated them as success and the audit record was silently dropped. Detect the error body and fail visibly. No retry: the server already consumed the nonce, and persistence failures are the server's job to solve; the client's job is to make the loss visible. Co-Authored-By: Claude Fable 5 * fix: give audit retries a delay long enough to outlive a restart The backoff was 1s then 2s, so all three attempts landed within about three seconds. That does not cover the case the retry exists for: a reverse proxy answering 502 while the api server restarts fails fast, so every attempt hits the same outage and the record is dropped anyway. Use 10s and 30s instead. The window is bounded on the other side - the api server dedups by nonce for five minutes, and a retry arriving after that expired would be stored twice - so the worst case is now about three minutes, leaving room under that limit. Derive the attempt count from the delay table so the two cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * fix: retry audit posts the server answered with an error body hbbs reports handler failures as 200 with an {"error": ...} body, and this treated them as final on the grounds that the server had already consumed the record's nonce. That is no longer how the server behaves: it releases the nonce when the write fails, and answers a post whose earlier attempt is still being written with an error as well. Both are exactly the cases where trying again is what gets the record stored, so giving up after the first attempt drops audit records the retry was added to save. Co-Authored-By: Claude Opus 5 (1M context) * fix: bound audit retries by elapsed time, and retry 408 and 429 The comment claimed the retry window fit inside the server's five-minute nonce memory with room to spare, and that was wrong: one attempt is up to 84s, not 12s, because post_request_ retries the TLS handshake up to four times at 12s each before the 36s TCP-proxy fallback. Three of those plus the delays is 292s against a 300s window, and a suspend between attempts stretches the wall clock without any bound at all, so counting attempts cannot bound this. Stop by elapsed time instead: no new attempt starts past 120s, which leaves the last one room to finish well inside the server's window. Also retry 408 and 429. Both are transient - the request timed out upstream, or a proxy is shedding load - but the 5xx test dropped the record after the first attempt. Co-Authored-By: Claude Opus 5 (1M context) * fix: only an empty 2xx body counts as a stored audit The success check was inverted: any 2xx body that failed to parse as an {"error": ...} object was reported as stored. A proxy interposing a 2xx maintenance page, or a malformed error value, therefore ended the retry loop with success and silently dropped the record - the exact loss the retry was added to prevent. The audit handlers' success contract is an empty body, so treat exactly that as success. A nonempty body with a valid error message stays a retryable server error; any other nonempty body is now a retryable "unexpected response body" instead of an accepted store. Both old and new hbbs answer success with an empty body, and no caller reads the returned text, so nothing depends on the previous acceptance. Co-Authored-By: Claude Opus 5 (1M context) * fix: do not start an audit retry past the deadline The deadline was only checked after an attempt returned, so an attempt could still begin up to one backoff delay past it - starting as late as ~150s and landing at ~234s, while the comment claimed no attempt starts past 120s. Re-check after the delay so the stated bound actually holds: the last attempt now starts before 120s and lands by ~204s, inside the server's five-minute nonce window with margin restored. Co-Authored-By: Claude Opus 5 (1M context) * docs: drop a retry rationale the server no longer backs The comment claimed hbbs answers a post whose earlier attempt is still being written with an error, so that retrying it is what stores the record. That stopped being true: hbbs now answers a concurrent duplicate as already stored rather than as retryable, having dropped the in-flight rejection along with the claim state machine it needed. Nothing in the handling changes - a 2xx carrying an {"error": ...} body is still retried, and that is still right, because the server releases the record's nonce when its write fails. Only the half of the rationale the server no longer backs is gone, since this comment is where the contract between the two repos is written down. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- src/common.rs | 52 ++++++++++++++++++++ src/server/connection.rs | 102 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/common.rs b/src/common.rs index cd35433e0f1..592ab2a45e3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1405,6 +1405,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, diff --git a/src/server/connection.rs b/src/server/connection.rs index 4dc07d366f1..25d9b6792c6 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1506,6 +1506,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))); } @@ -1555,6 +1557,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); @@ -1576,6 +1579,7 @@ impl Connection { v["typ"] = json!(typ as i8); v["info"] = serde_json::Value::String(info.to_string()); v["conn_id"] = json!(self.inner.id()); + 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); @@ -1603,9 +1607,103 @@ impl Connection { ); } - #[inline] 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) { From cc85685b96af6f51df2ac7a1d36a996dddc90bb2 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Wed, 5 Aug 2026 23:58:23 -0300 Subject: [PATCH 098/121] fix(linux): stop losing every inhibitor when the ScreenSaver name is absent (#15772) On Linux, keeping the host awake during an incoming session asks keepawake for three things at once: the display through org.freedesktop.ScreenSaver on the session bus, and idle plus sleep through logind on the system bus. keepawake takes the ScreenSaver one FIRST and abandons the whole request if it fails, and WakeLock::new discarded the error with .ok(). So on any session where that name is missing, RustDesk silently holds NOTHING - not the display inhibit it could not take, and not the logind inhibits it never got to. On a host whose logind IdleAction is not the default, that means the machine can suspend in the middle of an active remote session, with any capture backend. The name is missing on a GNOME login screen. Measured on a GNOME/Wayland GDM greeter: org.freedesktop.ScreenSaver answers "was not provided by any .service files" and cannot be activated, while org.gnome.SessionManager is on the same bus and its idle inhibit works there. Same machine, same state: with it held the output was still lit at 129.9 s of idle, without it the compositor disabled the output after 30.3 s. Disabled, not blanked - an idle compositor releases the CRTC, so there is no scanout left for anything to read. So on the failure path, take both halves separately instead of neither: - ask keepawake again without the display part, which restores the logind idle/sleep inhibits that have nothing to do with the missing session name; - and get the display half from whichever session interface this desktop has, trying org.gnome.SessionManager and then org.freedesktop.PowerManagement. Only the failure path changes: a session where the ScreenSaver inhibit works is untouched. Where no session interface answers, the log now names every one that was tried and the error each returned, which is the whole diagnostic for a desktop nobody here can test on. Verified on a GNOME/Wayland greeter with a live client: the inhibit is taken 86 ms before anything else happens on the connection, and appears to gnome-session as "RustDesk: incoming session (idle)". The PowerManagement entry is NOT verified - it is the interface KDE and XFCE implement, it costs one extra failed call where it is absent, and the log is what will tell us whether it is the right one. --- src/platform/linux.rs | 167 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 9 deletions(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index ab6b1879bef..06cee3092bf 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1982,18 +1982,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) + } + } } } From f5ab01f8bd779159765bb8fa5ed0b1d82fa9e6bf Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 6 Aug 2026 11:16:01 +0800 Subject: [PATCH 099/121] fix(clipboard): win, populate file formats (#15692) * fix(clipboard): win, populate file formats Signed-off-by: fufesou * fix(clipboard): prevent Windows file clipboard OOB access * reduce diffs to master Signed-off-by: fufesou * comments Signed-off-by: fufesou * fix(clipboard): win, OOBs and double free Signed-off-by: fufesou * fix(clipboard): win, check deep copy Signed-off-by: fufesou * comments Signed-off-by: fufesou * fix(clipboard): harden Windows clipboard memory handling - clear HGLOBAL aliases after ownership transfers - validate callback inputs and capability sets - bound file-content responses and close search handles on errors Signed-off-by: fufesou * fix(clipboard): harden Windows cliprdr memory safety - validate clipboard descriptors and response sizes - fix allocation ownership and cleanup paths - synchronize format-map access across callback and STA threads - prevent clipboard format TOCTOU races Signed-off-by: fufesou * Comments on stale remote file formats Signed-off-by: fufesou * fix(clipboard): check pointers before using Signed-off-by: fufesou * fix(clipboard): harden Windows COM error handling - roll back FORMATETC enumeration on deep-copy failure - keep the enumerator constructor internal - propagate IStream seek and read failures Signed-off-by: fufesou * explicity `WIN32_FIND_DATAW` Signed-off-by: fufesou * fix(clipboard): validate format data size and simplify lock cleanup Reject clipboard data exceeding UINT32_MAX before allocation and keep format-map cleanup and lock release within the owning function. Add boundary tests for response data sizes. Signed-off-by: fufesou * fix(clipboard): missing frees Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/clipboard/src/windows/wf_cliprdr.c | 646 +++++++++++++++++------- src/client/io_loop.rs | 2 +- 2 files changed, 462 insertions(+), 186 deletions(-) diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index d918ee1db70..c32a2025928 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -26,6 +26,7 @@ #define COBJMACROS #include +#include #include #include #include @@ -50,10 +51,17 @@ #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) { @@ -386,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; @@ -405,6 +416,7 @@ struct wf_clipboard 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; @@ -425,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); @@ -445,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); @@ -527,6 +542,9 @@ 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) @@ -1050,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)) @@ -1120,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) @@ -1127,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 { @@ -1226,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; } @@ -1307,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, @@ -1374,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) @@ -1398,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; } @@ -1419,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)); @@ -1473,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) @@ -1566,19 +1627,25 @@ 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 BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity) @@ -1612,6 +1679,7 @@ static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity) return TRUE; } +/* Requires format_map_lock until the clipboard STA thread has exited. */ static BOOL clear_format_map(wfClipboard *clipboard) { size_t i; @@ -1636,13 +1704,6 @@ static BOOL clear_format_map(wfClipboard *clipboard) return TRUE; } -static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard) -{ - clear_format_map(clipboard); - clipboard->copied = FALSE; - return ERROR_INTERNAL_ERROR; -} - static UINT cliprdr_send_tempdir(wfClipboard *clipboard) { CLIPRDR_TEMP_DIRECTORY tempDirectory; @@ -1700,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}; @@ -1718,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 @@ -1729,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) @@ -1741,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; @@ -1848,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; } @@ -1939,6 +2014,7 @@ static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 con 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; fileContentsRequest.streamId = streamId; @@ -1969,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; @@ -2066,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: @@ -2137,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)) @@ -2163,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()) @@ -2426,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; @@ -2464,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; @@ -2500,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) @@ -2563,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); @@ -2589,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 || + capabilities->cCapabilitiesSets > 1 || + (capabilities->cCapabilitiesSets == 1 && !capabilities->capabilitySets)) + return ERROR_INTERNAL_ERROR; - if (!context || !capabilities) + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) return ERROR_INTERNAL_ERROR; for (index = 0; index < capabilities->cCapabilitiesSets; index++) @@ -2632,18 +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) - return ERROR_INTERNAL_ERROR; + goto fail; if (formatList->numFormats > 0 && !formatList->formats) - return ERROR_INTERNAL_ERROR; + goto fail; if (!map_ensure_capacity(clipboard, formatList->numFormats)) - return ERROR_INTERNAL_ERROR; + goto fail; clipboard->copied = TRUE; @@ -2665,30 +2765,30 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, if (!wf_cliprdr_bounded_strlen(format->formatName, WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len)) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if (name_len == 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, NULL, 0); if (size <= 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } mapping->name = calloc((size_t)size + 1, sizeof(WCHAR)); if (!mapping->name) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len, @@ -2696,13 +2796,13 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, { free(mapping->name); mapping->name = NULL; - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name); if (mapping->local_format_id == 0) { - return wf_cliprdr_server_format_list_fail(clipboard); + goto fail; } } else @@ -2713,6 +2813,7 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, clipboard->map_size++; } + ReleaseSRWLockExclusive(&clipboard->format_map_lock); if (file_transferring(clipboard)) { @@ -2723,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 @@ -2761,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; } } @@ -2785,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; } /** @@ -2797,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; @@ -2886,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; @@ -2904,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; @@ -2930,6 +3047,7 @@ wf_cliprdr_server_format_data_request(CliprdrClientContext *context, if (FAILED(result)) { + IDataObject_Release(dataObj); rc = ERROR_INTERNAL_ERROR; goto exit; } @@ -2938,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++) { @@ -2998,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 { @@ -3021,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) { @@ -3043,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; @@ -3052,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)) { @@ -3078,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 { @@ -3105,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) { @@ -3134,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. @@ -3170,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, @@ -3299,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) { @@ -3310,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 @@ -3338,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) { @@ -3415,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 @@ -3443,6 +3640,17 @@ 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; /* @@ -3465,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. @@ -3476,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() @@ -3507,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) { @@ -3630,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; @@ -3647,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/src/client/io_loop.rs b/src/client/io_loop.rs index c0eb7fb57fd..4636c54f802 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -410,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 { From ddad47925c6f1e429e5dfd930cacad0be1f2721b Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 6 Aug 2026 01:20:57 -0300 Subject: [PATCH 100/121] =?UTF-8?q?feat(linux):=20DRM/KMS=20direct=20captu?= =?UTF-8?q?re=20for=20Wayland=20=E2=80=94=20no=20portal=20consent=20requir?= =?UTF-8?q?ed=20(#15420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(drm): opt-in DRM/KMS screen capture for Linux/Wayland adds an opt-in `drm` feature for unattended remote access on Wayland: it captures below the compositor via libdrmtap, so there is no xdg-desktop-portal consent dialog and it works at the login screen. off by default. when the feature is off the build is byte-identical. everything is gated behind feature = "drm" or lives only in the separate rustdesk-unattended-wayland deb, whose package name is the informed consent. architecture (agreed with the maintainer): the capture runs inside the root --service, which already holds the privilege it needs, and streams frames to the user --server over a service-scoped _drm ipc channel. libdrmtap is loaded with dlopen at runtime (no link-time dependency, so the base build is unchanged and it still runs on ubuntu 18), and the .so is built in ci from the rustdesk-org/libdrmtap fork and shipped only in the drm deb. no setcap helper. - service: DrmReader reads scanout directly via the dlopen loader; an IpcDrmCapturer serves _drm consumers with a per-connection capture worker; durable availability cache + pre-warm to avoid enumerate/re-probe restarts - capture: multi-display (targets the selected crtc), hardware cursor over _drm, transient-errno retry with a bounded stall, rejects non-32bpp scanouts before the frame copy - robustness: only active, crtc-bound outputs are offered (an unbound crtc_id=0 connector is filtered and a client-selected 0 is refused, both fall back to pipewire); a per-display rapid-rebuild guard demotes a flapping display to pipewire; per-display (not global) zero-frame failure tracking - root-service hardening: bounded frame allocation and a concurrent-connection cap so a malformed scanout or a buggy consumer cannot OOM or thread-exhaust the service; a negative availability verdict expires so displays that appear after startup recover without a --server restart; exactly-one .so selection in the packaging so a stale object is never silently shipped - build: libdrmtap.so cloned at build time from rustdesk-org/libdrmtap main and bundled only for the --drm deb; ci builds a separate rustdesk-unattended-wayland deb (incl. an ubuntu 18.04 container) - DRM_CAPTURE_SECURITY.md: threat model and hardening notes * feat(drm): phase-2 split, pass the dma-buf fd instead of the converted frame move the egl detile and rgba pack out of the root --service and into the unprivileged --server. the root now calls only drmtap_open + drmtap_grab_desc and exports a raw dma-buf fd; the fd rides the _drm channel over SCM_RIGHTS with a small descriptor (geometry, per-plane offsets/pitches, modifier, hdr) instead of the full rgba frame, dropping the per-frame copy. the --server imports the fd with drmtap_open_render + drmtap_convert_dmabuf, keyed by the import-once egl cache, and the render context is created and dropped on the recv thread. the _drm transport moves off Framed (which cannot carry a fd) to a bespoke sendmsg/recvmsg framing (DrmConn) that attaches one SCM_RIGHTS cmsg only when a fd is present and rejects a truncated ancillary message. the split symbols are bound optionally so an older libdrmtap still loads the cpu path, and the whole thing degrades to the cpu BGRA path or PipeWire when no render node is available. pins libdrmtap-sys to =0.4.13 with the Cargo.lock checksum. folds in the DP-MST, ldconfig-restart and per-display PipeWire-fallback review fixes and a udev hotplug refresh. * drm: address the phase-2 split review 1- do not depend on the libdrmtap-sys crate for the pin: its build.rs statically compiles the whole libdrmtap C tree and a CAP_SYS_ADMIN helper and links -ldrm/-lseccomp/-lcap, which defeats the runtime-dlopen model. keep drm a pure dlopen backend and pin the .so by the build.py DRMTAP_REF release tag, guarded by a strict vX.Y.Z regex. drops the now-moot Cargo.lock freshness CI checks. 2- render-node-less consumers no longer lose the stream: the --server signals need_cpu on DrmStart when it cannot open a convert context, and the --service streams the CPU-converted frame path for that connection instead of a dma-buf fd the consumer cannot detile (which used to fall through to a PipeWire path nobody can approve on an unattended seat). 3- mark PipeWire initialized only after every per-display capturer is created, so a partial failure retries instead of the flag falsely reporting a complete init. 4- reject a degenerate (zero width/height) or short CPU frame before it reaches PixelBuffer::new (which derives stride as data.len()/height, dividing by zero). 5- keep the export-ledger epoch at DRM_DISPLAY_GENERATION so a hotplug invalidates cached buffers (elision stays off until the recycled-fb_id inode case is handled). 6- validate the udev uevent source (kernel nl_pid, multicast) with recvmsg so a local process cannot unicast a spoofed drm-change event to the root listener. * drm: second review pass on the phase-2 split 1- make PipeWire init atomic: build every per-display capturer into owned staging first and publish them to CAP_DISPLAY_INFO only after all succeed, so a mid-loop Capturer::new failure neither leaves partial entries (which the next check_init would treat as already-initialized) nor leaks the raw pointers already created. 2- pin the immutable libdrmtap commit, not just the tag: git clone --branch follows a mutable tag, so verify the cloned HEAD equals DRMTAP_SHA in both the CI workflow and build.py, failing on a moved/compromised tag. 3- drop the stale comment claiming a libdrmtap-sys crate pin (the drm backend has no such dependency). * drm: harden the libdrmtap source pin 1- verify the commit-SHA pin on a reused checkout too, not only on a fresh clone: a stale or mismatched third_party/libdrmtap (e.g. from a failed clone) is now removed and the build fails instead of silently reusing unpinned source. 2- default DRMTAP_REPO to the fork that actually publishes the pinned tag, so a clean git clone --branch v0.4.13 resolves (and to the expected commit) instead of failing on a repo that does not carry the tag. * ci: make the pinned libdrmtap commit SHA literal do not let an inherited DRMTAP_SHA override the verified commit in CI, so the tag/commit pair is immutable there. build.py keeps the env override for local forks. * drm: only SHA-verify a git libdrmtap checkout, not a local source tree gate the commit-SHA pin check on third_party/libdrmtap being a git checkout, so a clone (fresh, reused, or a stale/failed one) is still verified, but a non-git source tree a developer placed there on purpose to build unreleased local libdrmtap is used as-is (it has no tag to verify). * build: request the libdrmtap shared_library target explicitly since libdrmtap 0.4.11 the project builds both a shared object and a static archive, so 'meson compile drmtap' is ambiguous. ask for drmtap:shared_library (rustdesk dlopens the .so and never links the archive). * drm: do not reject a non-BGRA scanout on the export side grab_desc exports the raw scanout dma-buf; the unprivileged converter handles every format libdrmtap supports (10-bit XR30/AR30 with tone mapping, HDR, CCS) down to RGBA. The fourcc gate copied from the CPU-mapped grab() wrongly closed the _drm stream for a 10-bit XR30 primary (0x30335258) that convert_dmabuf converts fine -- observed live on an i915 seat scanning out XRGB2101010. Keep the gate only on grab(), whose frame.format is already the converted BGRA. * drm: do not restart-loop a demoted display PipeWire cannot serve DRM and PipeWire do not share a display-index space: DRM enumerates one entry per connector while the portal often exposes a single whole-desktop stream at index 0. When a per-display DRM capture was demoted to PipeWire for a non-primary DRM index, cap_map.get(&display_idx) was None and the bail Err made ServiceTmpl::run retry get_capturer every 1s forever (a multi-monitor restart loop, latent until a display demotes). Degrade to the whole-desktop stream (index 0) PipeWire does provide instead of spinning. Healthy DRM displays return before this and are unaffected. * ci: build the libdrmtap shared_library target explicitly the CI .so-prebuild step used the same bare 'drmtap' meson target that is ambiguous since libdrmtap became both_libraries (0.4.11); ask for drmtap:shared_library, matching build.py. * drm: stop altering the stock (drm-off) Wayland path (review 3.2, 4.6) 3.2: get_capturer_for_display no longer falls back to cap_map[0] for a missing index. CapturerPtr is a bare *mut Capturer cloned by raw-pointer copy, so aliasing one entry to two display_idx values let two video-service threads call frame() on the same Recorder unsynchronised (data race / UB), reachable in a plain build via CaptureDisplays{set:[0,3]}. Restore the exact-index lookup + bail; a demoted DRM index is dropped from the advertised list at the source instead. 4.6: revert check_init to upstream (flag set before the per-display loop, direct insert). The staged-all-or-nothing variant turned a partial per-display failure into a permanent 1Hz retry loop and was not drm-gated. Both restore the drm-off build to byte-identical with upstream. * drm: address review findings 3.1, 4.2, 4.3, 4.4, 4.7 + minors 3.1: snapshot the stock flutter bundle before the CI drm relink and restore it before makepkg, so the official Arch package ships the stock cdylib, not the drm-enabled one. 4.2: wrap the drm block in a failure-tolerant subshell so a drm-only failure no longer aborts the stock deb/rpm/arch publish. 4.3: narrow the publish glob to rustdesk-[0-9]*.deb so the consent-bypass unattended-wayland deb stays an artifact, not on the public release. 4.4: rewrite the three stale DRM_CAPTURE_SECURITY.md statements to the split (default path passes a read-only scanout dma-buf fd over SCM_RIGHTS with an import-once cache; export validation is metadata-only; BGRA-over-the-wire is the fallback) and document that grab_desc's fd is O_RDONLY (DRM_RDWR dropped upstream, dup preserves it). 4.7: only short-circuit to the DRM cursor when it is authoritative (visible, or hidden in a pure-DRM session); fall through to the normal cursor path in a mixed DRM+PipeWire session. minors: thread the deb variant by feature not glob; TODO for the ld.so.conf.d system path; drop a stray blank line. All gated or whitespace so the drm-off build stays byte-identical. * drm: re-authorize the _drm stream per frame and auth the producer (review 3.3, 4.1) 3.3: DRM/KMS capture is not session-scoped -- the worker grabs a CRTC's physical scanout regardless of which session owns the display -- but the peer was authorized only once at accept. Capture the peer uid and re-check it at the top of the forward loop: root is always allowed, any other peer must still be the active-session uid, fail closed otherwise. A session change now tears the stream down within one frame (~33ms) instead of leaking the incoming user's screen to the outgoing user's --server. 4.1: connect_drm accepted any producer. Reject a non-root peer (peer_uid != 0) so a process that won the socket-path race cannot feed the consumer a display list, frames and dma-buf fds while the DRM path suppresses the portal consent prompt. * drm: validate cursor body length and coalesce _drm frames to latest-wins (review 4.1, 4.8) 4.1: the DrmCursor consumer handed the wire body straight to the client, which renders width*height*4 RGBA bytes. Reject a body shorter than that so a truncated cursor cannot make the client read past the buffer. The hidden-cursor sentinel is 0x0 with an empty body, for which the bound is 0 and the check is a no-op. 4.8: the _drm socket is a FIFO, so a consumer that drains slower than we produce (a 4K convert on a modest GPU) fell seconds behind stale frames. Drain the producer channel without blocking each tick and forward only the newest frame; replaced frames drop in place, closing the zero-copy OwnedFd and freeing the CPU-path pixel buffer. Cursor updates stay in order and are never coalesced away. * drm: keep the demoted-display list consistent instead of stretching PipeWire (review 4.5) A DRM display demoted to PipeWire has no geometry-consistent per-connector stream on a multi-monitor host -- the portal exposes a single whole-desktop stream. The fallthrough served that whole-desktop frame while the list still advertised the demoted connector geometry, so the client stretched the frame and offset all input by the connector origin (the primary-index-0 demotion reaches this even after the get_capturer_for_display exact-index fix). Dropping the display from the list is not an option: its position IS the capturer index, so a drop would shift every later display and desync get_capturer_info. So instead: get_display_infos advertises a multi-monitor demoted display OFFLINE at its stable index, and get_capturer_for_display serves the PipeWire fallback only when its rect matches the advertised geometry, else bails. A single-display host still falls through (whole-desktop == that display). All new logic is drm-gated. * drm: bound the _drm body read, stream-scope cursor teardown, refresh a stale verdict, drop dead clear (review 5) - recv_msg_timeout2 only gated the wait for the first byte, so a peer that sent one byte then stalled pinned the task forever. The same budget now also bounds the body read; a body that overruns is a hard error that tears the stream down (recv_msg bodies are small JSON, so a healthy peer never trips it). - The cursor cache is keyed by display index, which a rebuilt stream reuses, so a predecessor exiting after its replacement published a fresh cursor erased it. Stamp each entry with a monotonic per-stream epoch and compare-and-remove on teardown. - ProbeState::Available had no TTL, so an idle hotplug left a phantom display in enumeration. Give it a timestamp and refresh the list off the hot path once it ages past POSITIVE_TTL. The verdict stays true across the refresh (never bounces a live session to the portal) and the probe runs on a background thread (never blocks the async enumeration). - Remove the dead clear(): it is unreferenced, and wiring it into teardown would force the blocking re-probe on the next enumeration that swap_available_displays exists to avoid. * drm: unit-test the bespoke _drm SCM_RIGHTS framing (review 6) The _drm wire format is hand-rolled (length prefix plus an fd bound to the frame first byte) because Framed/BytesCodec cannot carry ancillary data, so it had zero tests. Add pure-userspace coverage over a socketpair: - a control message round-trips with and without an attached fd, and the received fd refers to the same open file (a byte written into the source is read back through it) - a raw length-prefixed body (cursor / CPU-fallback path) round-trips byte-for-byte - a forged length prefix past the JSON cap is rejected at the prefix - surplus fds packed into one cmsg keep only the first and close the rest - a control message truncated past DRM_CMSG_CAP is rejected (MSG_CTRUNC), not consumed - peer_uid_from_fd reads the socket peer credential the producer-auth path relies on * drm: address the self-review findings on the review rework Five defects an adversarial pass found in the previous commits: - refresh_available_async set the single-flight probe guard, then relied on the detached thread to clear it; if thread creation failed (EAGAIN) or the closure unwound, the guard leaked true and froze every future probe. Release it via RAII inside the closure and on a Builder::spawn error. - The _drm per-frame re-auth called the cached active_uid(), which on a cache miss (exactly during a session switch) falls back to a blocking loginctl seat0 lookup -- on the single-threaded _drm runtime, once per frame, a subprocess storm. Use a new cache-only accessor that never blocks and fails closed on a miss, and correct the comment: the stop is bounded by the active-uid cache cadence, not one frame. - set_drm_cursor inserted unconditionally, so a still-draining predecessor stream could overwrite (then delete on teardown) the cursor a replacement stream published for the same index. Make it a compare-and-set that ignores an older epoch. - recv_msg_timeout2 treated a spurious readable() wakeup with nothing consumed as a mid-frame stall and tore the stream down. Track whether any byte was consumed (drm_read_full sets it) and map a zero-progress deadline back to None (re-poll), reserving the hard error for a genuine partial-frame stall. * drm: release the probe single-flight guard via RAII on the cold path too The cold availability probe in is_available acquired DRM_PROBE_IN_FLIGHT and released it with a plain store(false) after a synchronous body; a panic there (e.g. a poisoned DRM_STATE lock) would leak the guard true and freeze both future probes and the refresh path hardened in the previous commit, since they share the guard. Hoist the release into a shared ProbeInFlightGuard used by both the cold probe and the refresh closure, so any exit -- normal, early, or unwinding -- clears it. * drm: source libdrmtap from rustdesk-org, pinned by sha (review 3.4) The dlopened .so is loaded into the CAP_SYS_ADMIN root service, so it should come from the maintainer-owned repo, not a personal fork. rustdesk-org/libdrmtap main is already synced to the exact commit we pin (c9cf0938 = v0.4.13) but carries no release tag, so point both build.py and the CI job at rustdesk-org and track main with the immutable commit pinned via DRMTAP_SHA. The post-clone sha check makes this fail-closed: main moving off the pinned commit fails the build instead of silently swapping the .so. The CI ref guard now accepts a vX.Y.Z tag or main (a loose branch is still rejected). Switch DRMTAP_REF to a tag if rustdesk-org later publishes one. * drm: dlopen libdrmtap by absolute path + unit-test the _drm admission and re-auth (review 5e, 6a) 5e: the deb dropped /usr/lib/rustdesk into /etc/ld.so.conf.d so the private libdrmtap could be found by soname -- a system-wide search-path entry that lets it shadow a system library for every binary on the host, which Debian Policy 10.2 forbids. Resolve it by absolute path (/usr/lib/rustdesk/libdrmtap.so.0) at the dlopen site instead, with the bare sonames kept only as a dev fallback, and drop the ld.so.conf.d file and the ldconfig/try-restart postinst entirely (the .so is present at its absolute path right after unpack, so the pre-warm resolves with no linker-cache step). The dlopen site is this PR's own code, so this is in scope, not a follow-up. 6a: extract the _drm admission bound and the per-frame re-auth decision into pure helpers (drm_conn_admitted, drm_peer_authorized) and unit-test them: admission admits strictly below MAX_DRM_CONNS and rejects at/above it; re-auth passes root always, passes a non-root peer only while it equals the active-session uid, and fails closed on a switched-away, unknown-session, or unknown-peer case. (The /proc/exe-mismatch rejection is exercised by the accept-time authorize call; unit-testing it in isolation would need a second process with a different exe, so it stays an integration concern.) * ci: run the _drm unit tests on every PR (review 6) The _drm unit tests are behind the opt-in drm feature, which the default workspace test job does not build, so they would sit in the tree unrun -- no better than no tests. Add a Linux step to the per-PR ci.yml that runs them with the feature on, alongside the existing ipc/auth tests. drm is a pure runtime-dlopen backend with no link-time deps (no libdrm/EGL/gbm) and the tests are pure userspace (socketpair framing, SCM_RIGHTS, the peer-auth/admission decisions), so this needs no GPU and no extra system packages. The main build/test stays on default features, so the shipped drm-off config remains the primary verified one. * drm: bump the pinned libdrmtap to v0.4.14 Point the DRM capture build at the libdrmtap v0.4.14 release commit (816766dedaba3140c613712ce97aa2614e8899e7) instead of v0.4.13, in build.py and the flutter-build workflow, and correct the scrap Cargo.toml note to describe the actual DRMTAP_SHA anchor. 0.4.14 keeps the same public API, so the dlopen consumer needs no change. * drm: address the consumer review (login-screen uid, frame flow control, hotplug) - Start the login-screen --server as the active seat0 greeter account instead of root, so the DRM capture GPU/EGL convert never loads the vendor GPU userspace in a privileged process. A genuine root graphical session has no lower uid to drop to and stays root, and if the greeter spawn fails we fall back to a root --server so the login screen stays remotable. Gated on the drm feature so the non-drm build is unchanged. - Bound the number of frames in flight on the `_drm` channel: the consumer acks each frame it finishes converting and the producer only sends while it holds credit, waiting on the socket otherwise. Without this the producer kept writing descriptors into the socket faster than a slow convert drained them and the consumer worked through an ever-growing backlog of stale frames. A zero-byte read or write on the ack path is treated as a closed peer rather than as success. - Forward a display list that became empty (last monitor unplugged) instead of dropping it, so the availability cache leaves Available rather than keep advertising removed displays. - On a topology change, invalidate the Wayland geometry cache and reapply the uinput mouse range for the new layout. The refresh runs off the frame-receive loop and is coalesced across the per-display receivers, so a multi-monitor hotplug runs one worker and the final layout wins. - Clear the prefer-CPU-convert hints on a topology change: display indices can be renumbered, so a hint learned for an old index no longer refers to the same physical display. Re-learned on the next convert failure. - Report a non-DRM-backed display when the DRM list is shorter than the sync list or any entry is offline, covering the present-but-demoted case. * drm: log why the uinput refresh worker could not start The worker released its coalescing slot and returned silently when the runtime failed to build, leaving the uinput range stale for the new layout with nothing in the log to explain it. * drm: gate only frames on send credit, never cursor or topology updates The credit check sat at the top of the producer loop and continued on exhaustion, so while a slow convert withheld its ack the loop never reached the code that forwards cursor updates and pushes a changed display list: the remote cursor froze and a hotplug went unreported until credit returned. The comment claimed those were not credit-gated; structurally they were. The loop now always receives and processes producer messages. Only the frame send is gated: when credit is exhausted the newest frame is held back (latest-wins, matching the existing coalescing) and flushed as soon as an ack lands, while cursors and the topology push go out unimpeded. While a frame is held the loop also waits on the socket, so an ack wakes it promptly rather than only when the next frame arrives; both select arms are cancel-safe. * drm: fix three defects in the frame credit gate Follow-up to the previous commit, from an adversarial review of it. - The ack wake-up skipped the coalescing drain. When the socket arm of the select won, there was no message to seed the drain loop with, so the channel was never polled that iteration: a held frame could be sent while a strictly newer one already sat queued, and a queued cursor waited for the next producer message. Seed the loop from the channel when we woke on an ack instead. - The loop could wait while holding a frame it was allowed to send. Credit replenished by the top-of-loop drain was not consulted before entering the select, so the frame waited for the worker's next message; if capture then returned WouldBlock it sat there until the stall teardown. Take whatever is queued without blocking in that case and fall through to the send. - The capture worker no longer had any backpressure. Draining the channel every iteration (needed so cursors keep flowing) means a full channel no longer parks it, so a consumer converting at a fraction of the capture rate made the privileged service keep grabbing frames that were then discarded -- a packed copy per frame on the CPU path, a PRIME export on the dma-buf path. The worker now skips the grab while the task is holding an undeliverable frame, and keeps polling the cursor so the remote pointer stays live. The gate is deliberately conditioned on holding a frame, not merely on having no credit: with nothing held the task blocks in recv() and cannot observe an ack, so gating there would stop the worker feeding it at all. The comment claiming the bounded channel backpressures the worker is corrected. * drm: gate capture on credit alone, and bound the no-credit wait Follow-up to the previous commit, from an adversarial review that modelled the loop with a real runtime, socket pair and worker thread. Gating the worker only while a frame was already held was wrong: those grabs are not wasted work, they keep the held frame fresh, because the coalescing below lets each newer frame supersede it. Pinning the worker at that moment therefore froze whatever frame happened to be in hand when credit ran out and shipped it stale once the ack landed -- measured at ~91ms average staleness against ~2ms with no gate at all. Gating on lack of credit alone, and waiting on the socket whenever credit is out rather than only while holding a frame, keeps the CPU saving (the worker still stops grabbing) with no staleness: the ack resumes the worker and what goes out is a fresh grab. Modelled at 0ms staleness and the same delivered-frame count, with 31 grabs versus 588 ungated. It is deadlock-free because the socket is watched in exactly the states where the gate is set. The no-credit wait is now bounded (5s). While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog; a consumer that stopped acking without closing the socket could otherwise hold this connection, its worker thread and the privileged DRM context open indefinitely. * drm: measure the no-credit deadline from the last ack, not the last wake-up The bound added in the previous commit was a timeout on the wait itself, so any wake renewed it -- and cursor messages keep arriving while frames are gated, so a consumer that had stopped acking but still moved its pointer would renew the deadline forever and never be torn down. Track when we last held credit instead and enforce the deadline against that, keeping the wait capped only so we still wake to re-evaluate it when nothing arrives at all. * drm: drop to Unavailable when the background refresh finds no displays The review asked for two things when the last CRTC disappears: push the empty topology to consumers, and stop advertising the removed displays. Only the first was done. The positive-TTL refresh still discarded an empty probe result and kept the previous list, so on an idle host -- where there is no live stream to carry the hotplug push -- enumeration kept reporting displays that were gone, exactly as described. It now transitions to Unavailable on an empty result, matching the hotplug path, while a failed probe (transient open/EACCES, not evidence the displays are gone) keeps the verdict and only restamps it. * drm: do not let a stale availability probe overwrite a newer verdict query_displays() in the background refresh runs unlocked because it is slow, so a hotplug push can publish a newer verdict while it is in flight; the refresh then overwrote it with its own older result. Harmless while it only replaced the list, but the previous commit made an empty result drop to Unavailable, so a probe that started while the monitors were gone could disable DRM on a host whose monitor had since come back. The refresh now samples the stamp of the verdict it is refreshing and publishes only if that stamp is still current. Every publish stamps a fresh Instant, so an unchanged stamp means nothing republished in between -- equivalent to threading a revision counter through every publish site, without having to keep all of them in sync. * drm: track availability publishes with a generation, and hold the probe guard across the whole path Two defects in the previous commit's staleness check. The single-flight guard was still created inside the spawned closure, but that commit added a DRM_STATE lock before the spawn. A poisoned lock there would unwind past the flag with nothing to clear it, leaving DRM_PROBE_IN_FLIGHT set and freezing every future probe. The guard is now taken immediately after the flag is acquired and moved into the closure, so it covers the lock, the probe, and a failed spawn alike. The explicit release on spawn failure is gone with it: it was not merely redundant but wrong, since by then another refresh may have acquired the flag and clearing it would let two probes run at once. The staleness check itself compared Instant stamps, which made correctness depend on an implicit invariant -- that every publish restamps -- spread across ten call sites; a future publish that reused a stamp would defeat it silently. DRM_STATE now carries an explicit generation, bumped by publish_probe_state, which every write to the state goes through. Instants are left to serve only the TTL checks. The failed-probe branch deliberately restamps without bumping: it touches the TTL, not the verdict, so a concurrent probe loses nothing by publishing over it. * drm: convert each display on the GPU that exports it The unprivileged converter opened its render context with drmtap_open_render(NULL), letting libdrmtap auto-select. On a multi-GPU host that can land on a different GPU than the one driving the display, and importing a scanout across vendors can fail permanently on an incompatible tiling modifier. The service already knows the exporting device, so it now names its render node (drmtap_render_node, libdrmtap 0.4.15) in each DrmDisplayInfo, and the consumer opens the converter on that node. The field is serde(default) and empty means auto-select, so a service and a server from mismatched builds still interoperate and a pre-0.4.15 .so degrades to exactly the previous behaviour. The path is realpath-gated to /dev/dri before it is opened, the same gate the capture device gets, since it arrives over IPC. When the named node cannot be opened the converter returns None and the existing need_cpu fallback runs the convert on the exporting GPU service-side, which is the most correct place for it anyway. Added a wire-compat test that a pre-render_node DrmDisplayInfo payload still decodes (empty node) and a current one round-trips the node. * drm: advertise the displays of every GPU, not just the first card A drmtap context is bound to a single DRM device, so the service enumerated one auto-detected card and advertised only its monitors. On a multi-GPU host every display driven by another card was invisible to the client, and its card-local CRTC id could not have been opened through the wrong device anyway. The service now enumerates every card (drmtap_list_devices, libdrmtap 0.4.15), opens one reader per device, and merges their displays into the one list, each tagged with its own card node and render node. DrmStart resolves the chosen index to that display's device + CRTC and the worker reopens the right card; the converter already binds the display's render node. Both new fields are serde(default) and empty means the single auto-detected device, so a pre-0.4.15 .so and a mismatched-build peer keep the previous behaviour exactly. Enumeration replaces the single-reader open in the pre-warm, the udev hotplug refresh, and the per-connection handshake, so a hotplug on any card is picked up and an all-monitors-off state now correctly publishes an empty list. The per-connection cache refresh re-enumerates all cards rather than only the connection's device, so serving one display never drops the others from the next handshake. Verified on a Jetson Orin (its two DRM devices, only card2 driving a display): list_devices reports card2/renderD129 with one display, enumeration produces exactly that display tagged to card2, and card1 (no active CRTC) is skipped - no phantom, no regression on the single-display case. * drm: bump the pinned libdrmtap to v0.4.15 * drm: do not guess the exporting GPU when the host has several render nodes The converter binds the render node the service names for a display, and falls back to auto-selection when that name is empty. An empty name is what an older libdrmtap produces: the service resolves it with drmtap_render_node, which only exists since 0.4.15, and rustdesk dlopens libdrmtap.so.0 by soname, so the runtime library can be older than the one the build was pinned to. Auto-selecting is not safe there. On a single-SoC multi-device host the wrong choice does not fail: a Jetson Orin exports the scanout from nvidia-drm while the first render node belongs to tegra, and importing the scanout on the tegra node SUCCEEDS and yields corrupted pixels. There is no convert error, so the prefer-cpu bit never learns anything and the stream simply looks broken with a clean log. Request the CPU-converted path instead whenever the exporter is unnamed and the host exposes more than one render node: the service converts on the device it already has open, which is correct by construction. Hosts with a single render node have nothing to pick wrong and keep the dma-buf path untouched. Verified on a Jetson Orin Nano, the two-device host: with a libdrmtap that lacks drmtap_render_node the capture used to come through visibly corrupted, and now falls back to the cpu path and renders correctly. With 0.4.15 the service names renderD129 and the dma-buf path is used as before. * drm: name the libdrmtap that was really loaded, and say so when it is stale Two hours went into a corrupted capture whose only symptom was a clean log saying "libdrmtap loaded: /usr/lib/rustdesk/libdrmtap.so.0 (v0.4.15)". The library behind that soname symlink was a pre-release 0.4.15 that reported the version but did not export drmtap_render_node, so the service silently stopped naming the exporting GPU. The log named the symlink it asked for, which is not evidence of anything, and the version it printed came from the library itself, which was the part that lied. Log the file the absolute candidate actually resolves to, and warn when a library reports 0.4.15 or newer while missing drmtap_render_node or drmtap_list_devices, naming that file: a version that claims features the symbols do not back means a stale or pre-release build, and the effect is invisible otherwise. Only the absolute candidate is resolved, because dlopen does not search the process CWD for a bare soname while canonicalize would. Also correct two places that no longer matched the code: the security document still described an /etc/ld.so.conf.d drop-in and an ldconfig trigger that build.py deliberately does not ship (the .so is dlopened by absolute path and the package makes the soname symlink itself), and the comment above the render node lookup still said an unnamed exporter always falls back to auto-selection. * drm: tighten the render-node count and the loader diagnostics Four corrections from a review pass over the previous two commits. Count only a render node whose name is renderD followed by a numeric minor. The prefix test also matched something like renderD.backup, which would have inflated the count and pushed a genuinely single-GPU host onto the CPU path. Log the load only after every required symbol resolved. load() still returns None when one is missing, so announcing success first could print "libdrmtap loaded" and then "libdrmtap not available" for the same library. Name only the capability each absent symbol costs: a library missing just drmtap_render_node loses exporting-GPU selection, one missing just drmtap_list_devices loses multi-GPU enumeration, and the previous wording claimed both were gone in either case. Fix the security document's audit step. The dlopen names the symlink by absolute path and the package registers no linker directory, so a leftover object beside it is not loaded on its own; what matters is where the symlink points, and a leftover only matters as what a stray ldconfig would repoint it to. Ask the auditor to read the symlink target instead. * docs: list every case that selects the CPU-converted frame path The security document described the CPU fallback without saying when it is taken, and the multi-GPU safety fallback added in this branch was not mentioned at all. Enumerate the four cases, including the one where the service could not name the exporting GPU on a host with several render nodes, and note that a single-render-node host keeps the DMA-BUF path. * drm: fetch libdrmtap by commit sha instead of cloning a branch `git clone --depth 1 --branch main` fetches only the tip of that branch, so the moment upstream pushes to libdrmtap `main` the pinned commit is no longer present in the shallow clone at all: the build fails on an unreachable object rather than on a mismatched pin, and it fails for a reason that has nothing to do with the checkout being wrong. In the release workflow the whole block is wrapped so the job stays green, which means the drm deb would simply stop being produced without anyone noticing. Fetch the sha directly instead. No branch or tag name takes part in the build now, so it survives every upstream push and cannot be affected by a ref being moved or repointed. DRMTAP_REF is gone, along with the regex that validated it. The post-fetch sha check stays, with a narrower job: a fetch by sha cannot resolve to anything else, so it now guards a reused checkout left at a different pin, which is exactly what a version bump leaves behind. It still removes that tree so the next run re-fetches cleanly. build.py is now the single source of truth for the pin. * drm: move the drm CI out of the stock workflow, and stop touching scrap/Cargo.toml The instruction was that nothing outside the feature should change while the feature is off, and the runtime code honors that, but the build plumbing did not. Start undoing that. ci.yml goes back to upstream byte for byte. The drm test step it carried now lives in a new workflow that only fires when a drm path changes, so a PR that does not touch this backend pays nothing for it. That new workflow also runs the whole rustdesk-crate test set with the feature on rather than filtering by the `_drm` test names, because the name filter skipped the sibling assertion that bounds `size_of::()`, which the new DmabufDesc variant grows. It gains a second job that fetches libdrmtap at the pinned commit, builds the .so and then asserts the contract the runtime depends on: every symbol the loader resolves, derived from the loader source so the two cannot drift, plus evidence that the EGL detile path is really compiled in. libdrmtap degrades to a CPU-only stub when the egl/glesv2 pkg-config files are absent on a build host, and nothing downstream noticed. Note the check looks for the dlopen target name and the import call, not for DT_NEEDED: EGL is loaded lazily on purpose so the privileged process never links the vendor GL stack, so an ELF-level check reports a false negative on a correct library. libs/scrap/Cargo.toml keeps only the added feature: the unrelated blank line before [dependencies.hwcodec] is restored, and the comment no longer describes DRMTAP_REF, which no longer exists. The feature is now drm = ["wayland"] because all three drm modules live inside the wayland arm of common/mod.rs, so scrap/drm alone compiled nothing; it worked only because the root crate always enables scrap/wayland. * drm: build the unattended-wayland deb in its own workflow, not in the release job flutter-build.yml goes back to upstream byte for byte. Three separate changes to the stock release path disappear with it: the drm variant built inside the release container, the snapshot and restore of the stock flutter bundle that existed only to keep the drm relink out of the archlinux package, and the narrowing of the publish glob to keep the consent-free deb off the public release. The deb now builds in the drm workflow instead, which also removes the failure mode the old placement forced: the whole block had to run in a subshell ending in `|| echo WARN` so a drm-only breakage could not abort the stock publish steps, which meant every failure in it, from the fetch to meson to packaging, kept the job green and silently stopped producing the deb. A separate job can just fail. The bridge generator is a reusable workflow, so this calls the stock one rather than duplicating the codegen. The deb is asserted rather than trusted: build.py can exit 0 without producing a package, so the job checks the file exists and that it carries both the real libdrmtap object and its soname symlink. It stays an artifact and never a release deliverable, and it is built on the runner rather than in the old container the stock debs use, so its glibc floor is higher than a released package. * drm: stop refactoring the shared packaging path in build.py generate_control_file goes back to upstream byte for byte: no extra parameters, no conditional inside it. The variant instead rewrites the control file that function just produced, so everything specific to the consent-free package lives in added code rather than in the shared one. That rewrite fails loudly if either anchor line stops matching, so a future upstream change to the control layout cannot quietly yield a variant deb wearing the stock package name. finalize_deb is gone. It had pulled the tail of both deb builders into one shared helper, which is a refactor of a path the feature has no business touching. Both builders now carry their upstream tail verbatim, with the drm work added as three guarded blocks: stage the library, retarget the control, rename the output. With the feature off, every line is upstream's. Verified rather than argued, by building both packages with this script: the drm deb is Package: rustdesk-unattended-wayland, carries Conflicts, Replaces and Provides on rustdesk, has libdrm2, libegl1 and libgles2 appended to Depends, and ships libdrmtap.so.0.4.15 plus its soname symlink. The stock deb is Package: rustdesk, carries none of those three fields, and contains no libdrmtap file at all. * drm: key per-display state by connector identity, and end a stream whose index moved The service binds a stream to (device, crtc_id), which survives a topology change. Everything on the consumer side addressed it by list index, which does not: drm_enumerate_all_displays concatenates per-card lists, so plugging or unplugging a monitor renumbers every display after it. Two consequences, one live and one remembered. Live: a running stream kept sending monitor A while the advertised list, and so the client layout and the injected-input rect, had come to mean monitor B. It only resolved if the stream happened to fail on its own. The stream now records what it was bound to and ends itself when its index stops meaning that, which routes the change through the rebuild the video service already does. Remembered: the zero-frame failure counts and the prefer-cpu verdicts were keyed by index too, so after a renumbering one monitor could inherit another's demotion or be forced onto the CPU convert path for a mismatch that was never its own. Both are now keyed by device plus connector name. The reasoning was already written down for one of these, in the comment above the prefer-cpu clear, and applied only there. That bulk clear is gone with it. It existed to limit the damage of index aliasing; with identity keys it would instead throw away a correct verdict, which costs a real convert failure to relearn, on every unrelated hotplug. Also fixes the drm workflow to skip the two tests the stock CI already skips. Both need a display server and fail on any headless runner, so the job would have gone red for a reason that has nothing to do with this feature. Verified by running the exact command: 88 tests, including the size_of::() assertion that the old name filter was hiding. * drm: end the session when the captured display changes geometry mid-stream A resolution DECREASE wedged the stream. The encoder is sized once, from CapturerInfo at capturer build time; check_display_changed returns None on Wayland, so the periodic display-changed broadcast never fires there; and convert_to_yuv only bails when the source is LARGER than the destination. A smaller frame therefore passed all three and was encoded into the previous canvas, leaving stale content along the right and bottom edges for the rest of the connection. An increase recovered only by accident, because convert then refused and the service rebuilt. This is ours to contain rather than merely inherited: the DrmDisplaysChanged handler re-broadcasts the new geometry through SYNC_DISPLAYS, so the client layout and the pixels it receives actively disagree, where before there was no topology signal at all. The capturer now records the geometry its session was built with and returns a hard error from frame() when a dequeued frame differs, which routes a shrink through the same rebuild an enlargement already takes. got_frame is set first so a session that did deliver frames is not counted as one of the zero-frame sessions that demote a display to PipeWire. The general fix belongs to the Wayland path rather than to this backend, and is filed separately as #15695. Four tests cover it, the first in this file: the matching size is delivered, a smaller and a larger frame both end the session, and an unknown session size stays out of the way instead of rejecting everything. * drm: refuse a libdrmtap that cannot do the split export The root --service must never load libEGL/libGLESv2: the point of the split is that it exports the scanout dma-buf and the unprivileged --server converts. Two paths could still break that, both because the loader accepted a library too old to export. drm_prewarm() called grab() when the loaded .so had no drmtap_grab_desc, and grab() maps and detiles, so the privileged process pulled in the vendor GL stack at startup, before any consumer had asked for a frame. The per-connection capture loop then did the same for every frame, through the CPU fallback. The version guard could not prevent it: it compared the ABI major only, and this library is still 0.x, so every release it has ever made passed. Add a floor at 0.4.9, where the split entry points landed, and require the three split symbols, which also rejects a build that reports a new enough version without carrying them. That is not hypothetical: a pre-release stamped 0.4.15 shipped without the multi-GPU accessors. Both refusals fall back to PipeWire/portal and say which file and which symbols, at warn level. The split symbols are no longer Options, so the type system carries the guarantee instead of a convention. What is left of the CPU path is only what it was meant to be: the consumer has no render node of its own, or the seat exports no transferable dma-buf. Both are facts about the hardware, with no alternative that keeps the stream, and neither is a property of which file was on the load path. Verified against the real library on i915. With 0.4.15 the export path captures a tiled XR30 scanout and libEGL stays out of /proc/self/maps, while the old grab() branch maps it, so the finding reproduces. A stub reporting 0.4.8 and a stub reporting 0.4.15 without the split symbols are both refused, each with its own diagnostic. The mirrored repr(C) layouts are unchanged across 0.4.9 to 0.4.15, checked field by field against include/drmtap.h at both ends, so the floor costs no compatibility that was real. * drm: move the _drm channel and its producer into src/ipc/drm.rs src/ipc.rs is the file every unrelated IPC change has to be read through, and this branch had grown it from 2227 lines to 4112. Move the DRM half out, into the same #[path] submodule form the file already uses for ipc/auth.rs and ipc/fs.rs, so it lands as ipc/drm.rs beside them. What moves: the two payload structs, the producer that runs in the root --service, and the bespoke SCM_RIGHTS framing the channel needs because Framed/BytesCodec cannot carry ancillary data, plus their tests. What stays is the Data variants, which belong to a shared enum and cannot live anywhere else, and three re-exports so every existing call site keeps the path it already uses. ipc.rs is 2285 lines now, 58 above upstream instead of 1885. The move is content-identical: the only edits are the 39 per-item cfg attributes, redundant now that the module is gated once at its declaration, and the test module cfg that becomes a plain cfg(test). Checked by extracting the moved ranges from the previous commit and comparing them line by line against the new file. Both configs build with no new warnings and the same 92 tests pass, 14 of them the drm ones that moved. * drm: bound the _drm accept path (M1, M2, M8) M1: authorization is now done on the blocking pool. It reads the active session uid, which on a cache miss forks loginctl, and the socket is 0666 so any local uid can make us do it. The same call exists for _service, but this runtime is shared by every live capture stream, so a stall here hitches frames instead of delaying one config sync. M2: the handshake was a loop that ignored unexpected messages, which restarted the ten second budget on each one, so a peer sending junk just inside the timeout held a worker thread and one of the eight connection slots for as long as it liked, and eight of them denied DRM capture entirely. It is one receive now, and anything that is not DrmStart closes the connection: the consumer answers the display list with DrmStart and nothing else, so there is nothing legitimate to skip past. M8: dropped the extra unauthorized-connection warn. log_rejected_service_connection inside the authorization already logs the rejection with the peer and active uid and rate limits it to one line per five seconds, which is exactly what a world-connectable socket needs; the second line had no throttle and handed anyone who can connect an unbounded log write. Both configs build, 92 tests pass. * drm: stop the two states that never settle (M4, M6) M4: a dead producer left the availability verdict positive forever. The background refresh keeps a positive verdict on a failed probe, which is right for one failure and wrong for a run of them: if the root --service dies while this --server lives, every probe fails, the cached list keeps being advertised, and every display restart-loops. Three consecutive failures now drop the verdict to Unknown, not to Unavailable, because the evidence is about the producer and not about the hardware, so the next enumeration probes from scratch. The cold probe also resets its own failure budget on success: it was never reset, so the five strike allowance was spent once per process and a later probe demoted on its first failure. M6: a display that can never be grabbed churned PeerInfo about every 35 seconds for the life of the process, because the cooldown was flat: demote, wait 30 s, get advertised online, burn four sessions in a few seconds, demote again. The cooldown now doubles per demote cycle up to 8 minutes. Recovery is unchanged in the way that matters, since the count is erased the moment the display delivers a frame rather than decaying with time, so a monitor that comes back is served immediately. Also, while changing that map: a zero-frame session on a display with no connector identity was recorded under the empty key, which is the same aliasing H2 removed for indexes, one unidentifiable display would have demoted the next one. It is skipped now, as the comment above it always claimed. Two new tests cover the backoff schedule and the reported 35 second cycle. 94 tests pass, both configs build. * drm: give the DRM uinput update the timeout and the bookkeeping (M3, M6) The DRM path sets the uinput absolute range itself, because it bypasses check_init. That copy awaited update_mouse_resolution raw, and it was missing three things check_init has sixty lines above it. No timeout: uinput set_resolution reads its reply with no timeout of its own, so a hung uinput socket blocked every video-service start on this branch, and wedged the hotplug worker inside rt.block_on with UINPUT_REFRESH_BUSY latched true, after which every later hotplug refresh was silently skipped for the process lifetime. It is bounded at 3 s now, the same bound check_init uses. No bookkeeping: it never called set_wayland_uinput_rect or set_wayland_layout_baseline, which is why the #15601 layout-drift remap never activated on the DRM path. Both are recorded now, and only after a successful apply, so a transient failure is retried rather than remembered as applied. No cache invalidation: the cached Wayland layout can predate compositor changes made while no session was active, which is the case #15601 is about. Dropped first, as check_init does. It also stops reprogramming the device when the range has not changed (M6): a display in a rebuild loop called this about once a second, and reapplying an identical range is an IPC roundtrip plus a uinput reconfiguration under a user who may be at the console. The layout baseline is still re-snapshotted on every call, since it is what the client coordinates are measured against. Left as a separate copy rather than folded into check_init: check_init ships in every Linux build and the standing rule for this feature is that the drm-off build does not change by a line. Both configs build, 94 tests pass. * drm: check the greeter server is alive, not just spawned (M5, M10) M5: the greeter fallback tested the wrong thing. start_server reports whether the SPAWN succeeded, so a greeter account that cannot actually run the server, a nologin shell or a hardened home, leaves a child that exits at once; the loop sees only that the child is gone and respawns it as the greeter forever, never reaching the root fallback, and the login screen becomes un-remotable on a host where it used to work. It now requires the child to still be alive after a one second grace before accepting it. A server that dies later than that is a different, transient failure and the existing restart throttle already bounds it. While there: the whole greeter branch is now inside the drm cfg, so the drm-off build is upstream's single start_server line again rather than a run_as_greeter variable that is always false. M10: two monitors of the same model and resolution whose names do not normalize to the compositor's matched no output at all, so both kept the DRM origin, which is (0,0) for independent CRTCs. The client stacks them and injected coordinates hit the wrong monitor with certainty. Unmatched connectors now take the next free output in layout order, preferring one of the same physical size, and say so in the log. That is at worst a swap of two identically sized rectangles, and the layout stays coherent. The same pass also stops one output being claimed by two connectors, which the unique-resolution rule allowed. The assignment is now a pure function, so the cases are testable without a compositor: five tests cover the naming difference, the identical-monitor case, the double claim, name match beating the fallback, and more connectors than outputs. 99 tests pass, both configs build. * drm: stop reallocating and recopying whole frames (M9) The CPU fallback moved a scanout four times: the producer packed it, the kernel carried it, next_raw allocated and zeroed a fresh buffer to read it into, and the consumer copied that into the slot. At 4K30 the last two are about 8 GB/s of memory traffic that does nothing. next_raw_into reads the body straight into a buffer the caller owns, so the kernel copy lands where the frame is going to live, and resize costs nothing once a buffer has seen one frame of that size. The frame buffers then circulate instead of being freed and reallocated: whatever a new frame displaces goes back on offer, both when the encoder consumes one and when a frame is superseded before anyone reads it. The dma-buf path still copies once, because the convert output is borrowed from the render context and only lives until the next convert, but it copies into a recycled buffer and does it outside the slot lock, so a multi-megabyte memcpy no longer holds the encoder off the slot. Steady state is now one allocation for the whole session on both paths, and the CPU path carries the pixels twice instead of four times. The cursor body reads into its own buffer and is moved into the cursor cache rather than copied; it is small and rare, so it stays out of the frame recycler. Two tests: the raw body round trip now also covers a shorter body reusing the buffer, so a stale tail cannot survive into it, and a new test asserts the frame buffers circulate by allocation identity rather than by inspection. 100 tests pass, both configs build. * drm: the polish list, and a correction to my own ABI floor The version floor I added two commits ago was one release too low. drmtap_open_render and drmtap_convert_dmabuf are 0.4.9, but drmtap_grab_desc is 0.4.10, so a genuine 0.4.9 library passed the version gate and was then refused by the symbol gate with a message that called it a stale or pre-release build, which it is not. The floor is 0.4.10 now, the release where the whole split API exists, and the test lists 0.4.9 among the rejected versions with the reason. ExportLedger is deleted. DRM_FD_ELISION was false, so should_send_fd returned true at its first branch and about sixty lines of eviction and epoch machinery were unreachable, untested, in a security sensitive file. Why it was disabled is worth keeping, so here it is: eliding the fd on an fb_id the converter has already imported looks free, but the kernel can recycle an fb_id onto a different buffer with identical geometry and modifier, and the exporter cannot see the dma-buf inode that would tell the difference, so the elision can serve a stale EGLImage. Sending it is cheap, the converter imports once per buffer and closes the surplus fd, and libdrmtap's own cache keys on fb_id AND inode and can only re-import when it is handed a real fd. That reasoning now lives here instead of in dead code. The rest: - num_planes is clamped on the consumer before it reaches the C descriptor. The producer normalizes it and must be root, so this is only defense in depth, but the wire is the one place the value arrives from another process. - warm_availability returns early on X11. Nothing there can consume a DRM stream, and probing makes the ROOT service open DRM readers, so an X11 host running a drm build was paying that at every startup for a path it can never take. - drm_cursor_id no longer clones the cursor. The cursor service polls it at frame cadence to compare eight bytes, and a 256x256 cursor is 256 KiB. - The premultiplied ARGB pass-through is now documented as matching the XFixes path, since that is why it is correct rather than an oversight. - cfg hygiene: input_service.rs uses all(target_os = "linux", feature = "drm") like every other site, and active_uid_cached is gated with the feature too, which also removes a dead-code warning from drm-off Linux builds. - Nits: DrmConn is pub(crate) like its constructors, new_drm_listener is no longer async with nothing to await, and the two anyhow! plus return Err pairs are bail! as the codebase writes them. - DRM_CAPTURE_SECURITY.md moves to docs/ with the other docs, and its "no privileged child process is ever spawned" claim is corrected: an empty helper_path is not a disable switch in the C, find_helper searches six fixed paths and would exec one if the direct export ever failed. It is unreachable here for two independent reasons, the root service holds CAP_SYS_ADMIN so the direct path succeeds and the package builds no helper at all, and the paths are root-writable only, so the accurate statement is that this package never installs one, not that it can never happen. - The comments that narrated the review rather than the code are rewritten to say what the code does. One of them had also drifted: the convert context is opened before we answer with DrmStart, not before the handshake. Both configs build with no new warnings, 100 tests pass. * drm: one DisplayHealth per connector, and the last index-keyed map The three per-display verdicts are three answers to one question, can this display be captured over DRM right now, and they already fed each other: the rebuild cadence and the zero-frame streak end in the same demotion, and the convert verdict is what keeps a multi-GPU display off the dma-buf path so it never gets there. They are one struct now, keyed by connector identity. This also closes a real leftover from H2. Two of the three maps were re-keyed by identity then; the rapid-rebuild map was not, and stayed keyed by list index. A hotplug that renumbers the list therefore moved a flap verdict onto whichever monitor took that slot, which is the same defect in the third map. There is no index-keyed per-display state left. Behaviour is otherwise the same, with one improvement that falls out of the merge: when a demotion cooldown expires, clearing the streak now keeps the display's other state rather than replacing the whole entry, so a build cadence and a convert verdict survive a retry the way they always should have. One test for the demoted predicate, including that a higher demote count still holds a display that a lower one would have released. 101 tests pass, both configs build. * drm: bound the GITHUB_TOKEN in the drm workflow CodeQL flagged the new workflow for not declaring permissions, which is fair: every job here only checks out, builds and tests, and the artifact up/download in the deb job authenticates with the runtime token rather than this one, so contents: read is the whole requirement. Declared at the workflow level so the reusable bridge workflow it calls inherits the same bound. The stock workflows do not declare it either, but they are upstream's and this feature does not touch them; a new file can start out right. * drm: make the outer handshake budget dominate the inner one Two findings from the review bot on our own fork, both worth taking. The caller waited HANDSHAKE_TIMEOUT_MS + 500 for the receive thread to hand back the display list, but that thread is allowed to spend more than that: the connect budget, and then recv_msg_timeout2 applies its argument twice in the worst case, once waiting for the first byte and once for the body. So on a slow connect the outer timer fired first and abandoned a handshake that was still inside its own budget. The wait is now derived from those parts rather than written as a constant, so changing either one cannot silently invert the relationship again, and the two connect sites use the named constant instead of a literal. The cursor cache insert shadowed hcursor under a cfg, so the same line meant the requested id in one build and the served id in the other. It is a separate name now, with the reason on it. Not taken, and why: the bot also suggested making DrmCursorData carry width and height as u32 to match the wire. They are i32 because that is what they feed, protobuf CursorData declares both as int32 and platform/linux.rs assigns them straight across. One cast has to exist somewhere, and it belongs at the boundary where the values are already being validated, not at the consumer. 101 tests pass, both configs build. * drm: bound the body read, and stop the empty key from aliasing displays From the second review bot on our fork. Two of these are real and one of them is mine from earlier today. A raw body read had no deadline. Only the header was bounded, and drm_read_full loops on readable() until it has the exact length, so a producer that wrote a header and then stopped (crashed, stopped, wedged) pinned the consumer receive thread forever. That thread is also the one that observes the stop flag, so every capturer rebuild would have stranded another thread and its render context. The whole body is bounded now, and an overrun is a hard error because the header is already consumed and the frame cannot be resumed. get_capturer_info collapsed an unknown connector identity to the empty string and then read and wrote the health map under it, so two unidentifiable displays shared one entry and one could demote the other. That is exactly the aliasing frame() refuses to take part in; I fixed one side of it this morning and left the other. The key is an Option now and both blocks skip when it is None: a display with no identity simply carries no health. Also from the same pass, smaller: - build.py validates the shape of DRMTAP_SHA and DRMTAP_REPO before they reach a shell command. Both are env-overridable and get interpolated, and beyond the injection argument, an abbreviated sha would defeat the point of pinning while failing in a much less obvious place. - the workflow's push path list is now identical to the pull_request one. It was missing four paths, so a push to master touching only those would have skipped re-verification. - the checkouts set persist-credentials: false, so the token does not stay in .git/config for the rest of the job. - a concurrency group supersedes a stale PR run, but never cancels a master run, whose whole purpose is to record that a commit was verified. Not taken: reading VCPKG_COMMIT_ID and FLUTTER_VERSION from a shared .env. There is no .env at the repo root, and the stock ci.yml and flutter-build.yml hardcode those same two values, so this matches what is already there. 101 tests pass, both configs build. * drm: test the half of the accept-time authorization that had none The review called the accept-time authorization decision the single most important invariant in this PR, and noted it has no test. Half of it did: drm_peer_authorized_matrix covers the uid rule. The other half, the /proc//exe identity match that stops a DIFFERENT program running as the right uid from being handed the screen, did not. We said last round that testing it needs a second process with a different executable, so it was integration rather than unit work. That was too pessimistic: the negative case needs ANY foreign executable, not a second build of rustdesk, and /bin/sleep is one. So the test covers all three outcomes: our own pid matches, a live process running another binary is rejected, and a peer whose pid cannot be resolved is rejected rather than admitted. The test synchronizes on the child having exec'd before it looks. spawn returns while the child is still a copy of us, and until exec completes /proc//exe points at OUR binary, so reading it too early sees a match and the assertion passes for the wrong reason. It failed exactly that way under the parallel suite and passed when run alone. A real peer has necessarily exec'd and connected before it can be authorized, so the window exists only in the test. 102 tests pass, three consecutive full runs, both configs build. * drm: make the refresh decision a pure function, and test it The review named two untested things: the accept-time authorization decision, covered by the previous commit, and the availability/demotion state machine. The demotion half got tests with the backoff work; this is the other half, what a completed background refresh decides. It is extracted rather than tested in place on purpose. The effects touch process-global state, DRM_STATE and the failure counter, which parallel tests cannot share, so a test driving them would be intermittent by construction, which is the kind of test nobody ends up trusting. The decision itself has no such problem, so it is now a total function over the probe result and the consecutive failure count, and the closure applies it. Two tests: the decision table, including that a run short of the threshold keeps a working verdict and the threshold gives it up; and the symptom the policy exists for, a root service that dies while this server lives, where every probe fails from then on and the verdict has to be given up in bounded time, to Unknown rather than Unavailable, because what we learned is about the producer and not about the hardware. 104 tests pass, both configs build. * drm: count a display whose frames never match its advertised size The display list carries the CRTC mode and a frame carries the scanout framebuffer. Those are two different numbers whenever a CRTC scales a smaller buffer up to its mode, so such a display fails the geometry guard on the FIRST frame of every session, having delivered nothing. That path marked the session as having produced frames, which is what the zero-frame streak uses to decide a display cannot be served over DRM at all. So the demotion to PipeWire never armed and the display rebuilt until the rapid-rebuild guard caught it seconds later, under a message about a mid-session change that never happened. Count it instead, through the same bookkeeping the stream-died path uses (now one helper, so the two cannot drift), and say which of the two cases the error is. The unit test asserted the old behaviour on a capturer that had never delivered a frame, so it is split into the mid-session case it meant to cover and the first-frame case it was silently locking in. * drm: make an unpinned libdrmtap deliberate, and reject --drm off Linux Three ways to build a different libdrmtap than the pinned one (DRMTAP_REPO, DRMTAP_SHA, DRMTAP_PREBUILT_DIR) were each silent, and the last skips the sha verification entirely. The claim this feature rests on is that the privileged capture library is the reviewed object at the pinned sha, so any build that is not that one now has to say so: the overrides still work and still cover local work and cross-builds, but they need DRMTAP_ALLOW_UNPINNED=1 alongside them and the build prints what it did. --drm on Windows or macOS was accepted and then dropped by get_features(), so it produced a stock build that looked like a DRM one. Reject it. Also test the _drm body-read deadline, which nothing exercised: the header and the body are separate reads, so the caller budget does not cover the second one and a regression there would silently reopen the stall. * drm: treat an empty DRMTAP_PREBUILT_DIR as unset in the pin gate build_libdrmtap_so() tests it for truthiness, so an empty value means no prebuilt directory. The gate compared it against None instead, and would have demanded the opt-in for an override that was never going to happen. * drm: never latch the uinput refresh slot, and bound the source stride The uinput refresh worker released UINPUT_REFRESH_BUSY on its two normal exits only. The body locks several process-wide mutexes and does a Wayland roundtrip, so an unwind there left the flag set for the process lifetime, and every later hotplug then skipped the spawn and never reapplied the uinput ABS range: the stale-range, wrong-output symptom the refresh exists to prevent. This file already had the answer for the probe flag, one screen away, and the hazard is called out in wayland.rs. Fixing one site and not the other is the same miss as the hotplug maps. The slot is deliberately handed back and re-taken mid-loop, so the guard tracks ownership rather than releasing unconditionally: a plain RAII drop would clear a flag a replacement worker owns. drm_reader bounded only the destination (w*4*h) while the row loop reads up to (h-1)*stride + w*4, so a large stride read past the mapping and could overflow usize in y*stride. drm_render::convert already bounds stride*h; the privileged half must not be the weaker of the two. Also give the drm CI jobs a timeout, so a hung meson or vcpkg step fails in an hour instead of six. * drm: refuse to ship a libdrmtap built without the EGL backend libdrmtap treats egl/glesv2 as OPTIONAL: without their headers and pkg-config files meson silently builds a CPU-only stub. The stub still exports every symbol the loader gates on, so nothing downstream notices, and the split capture 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. Our CI asserts this on the .so it builds; a developer or packager running build.py got no such check. Assert on the artifact rather than passing -Degl=enabled: that option only exists in libdrmtap past the pinned 0.4.15, and checking what was actually produced also catches a stale or substituted object, which a build flag cannot. Same two markers CI looks for, and for the same reason an ELF-level check does not work: EGL is reached by lazy dlopen so there is no DT_NEEDED. * drm: gate the libdrmtap ABI on the minor, and skip the warm probe on X11 Two items from the review that I had recorded as done and were not. The ABI check had a floor and no ceiling, so 0.5.0 and 0.9.9 passed. Under 0.x semver the minor is the breaking axis, and libdrmtap freezes only drmtap_device and drmtap_dmabuf_desc: drmtap_frame_info, drmtap_display, drmtap_config and drmtap_cursor_info are not frozen. A 0.5.0 adding one field to drmtap_frame_info still reports major 0, so we would have loaded it and read every field at the wrong offset, in the root service. It now requires the verified minor; a 0.5.x needs a deliberate bump after comparing the layouts. The unit test asserted the opposite of this, in as many words ("0.5.0 must pass"), so it was holding the hazard in place. Replaced. warm_availability ran on X11 too, where every consumer of the verdict sits behind an !is_x11() check, so the root service opened DRM readers for a path the session can never use. * drm: close the full-review findings (a third latched flag, and two escapees) The one that matters: the display-cache refresh worker was the THIRD copy of the wedged-flag hazard. catch_unwind covered only the enumeration, and thread::spawn panics on EAGAIN after RUNNING was already swapped true, so either path parked the flag for the process lifetime and every later refresh - including every udev hotplug - returned early forever. Same ownership guard as UINPUT_REFRESH_BUSY (the flag is handed back and re-taken mid-loop, so an unconditional RAII release would clear a replacement worker's flag), plus a fallible spawn whose failure drops the closure and releases the slot. DRM_PROBE_IN_FLIGHT, UINPUT_REFRESH_BUSY, now this: the lesson stays 'grep for every site with the shape', and twice was not enough. Two findings had been flagged in an earlier round and escaped the ledger: - an unrecognized convert-output fourcc fell through to 'present as BGRA' with a debug log, where every sibling validation in that function is a hard error that lets the caller fall back to PipeWire. A 64bpp output passes the stride check and encodes garbage. Hard error now. - the trust-boundary validation constants (fourccs, MAX_DIM, MAX_FRAME_BYTES) were declared independently on both sides of the split. Hoisted into drm_reader, imported by the converter, so the two halves cannot drift apart about what data they will touch. The rest: - the CI symbol extraction dropped any loader symbol containing a digit and degraded to a pass-with-zero-iterations no-op if the b"..." literals were ever refactored; digits allowed, count asserted, notice de-hardcoded. - 'drm' in features was a substring test on the comma-joined string, so a future drm-lease feature would have shipped the consent-bypass deb without --drm. Exact membership now. - the security doc claimed the deb is built on an ubuntu18.04 container; the only deb job runs on ubuntu-24.04. The 18.04 sentence now says what is true: 2.4.95 is an API floor, the binary floor is the build host's. - DRM_DISPLAY_CACHE poison handling was recover-in-the-writer, panic-in-the-readers; both readers now recover like the writer. - the producer prewarm ran on X11 where no consumer can connect, the same inconsistency just fixed for warm_availability. The listener still starts (the service outlives sessions; a later Wayland login must find the socket), only the prewarm is skipped. * drm: measure the verification deb glibc floor and put it in the artifact name The workflow already said in a comment that this deb is a verification build with a higher glibc floor than the release debs, because it builds on the runner rather than in the ubuntu18.04 container the stock job uses. A comment in this file is not visible to whoever downloads the artifact from the Actions UI, and the name was a bare rustdesk-unattended-wayland-x86_64.deb, so it read like something installable anywhere. The floor is now read off the built object with objdump and goes into the artifact name, so the constraint travels with the file. Measured rather than stated: a hardcoded number would drift the next time the runner image moves. Verified the pipeline against a real deb here (2.39). Restoring the container build is the other option and is cheap to do -- the recipe including the two 18.04 traps is still in this repo's history -- but it belongs with a deb that is actually distributed, not with a job whose contents are already asserted in-place. * drm: the same latched-flag bug a fourth time, in my own fix for the third I built UinputRefreshGuard INSIDE the spawned closure, so it only covered paths where the closure ran. thread::spawn panics on EAGAIN after the swap, so no guard existed and the flag stayed set for the process lifetime, which is the exact failure the guard was introduced to prevent. I then wrote RefreshSlot correctly - constructed before the spawn, moved in - two hours later and did not go back to fix its sibling. Both are right now, and the spawn is fallible in both. Also from the review: - DRMTAP_PREBUILT_DIR returned before the EGL-stub assertion, so the check only guarded the source build. That is backwards: prebuilt-dir is the widest override (no fetch, no sha check, an object this script never sees), the likeliest to hand over a stub, and the path our aarch64 cross-build actually uses. Verified the assertion accepts a real .so and rejects one built with -Degl=disabled. - convert() bounded only the frame libdrmtap returns, not the descriptor going in. offsets/pitches address plane ranges inside the dma-buf, so those are what a malformed pair would reach past. Bounded per populated plane, the same way the export side is. Defense in depth (the producer is root-authenticated and libdrmtap validates against the fd since 0.4.12), but the two halves should agree before the C sees the data, not after. - the flutter patch step used '[[ test ]] && git apply' as its last command, so the step would FAIL rather than skip the first time FLUTTER_VERSION moves off 3.24.5. Explicit if/else, and the values now come from the environment instead of ${{ }} interpolation, which also clears zizmor's template-injection warning. Checked both branches. Declined: the cursor id/cache-key convergence finding. Both accessors use one selection over one map, so they can only disagree across a publish race, and state.hcursor is already set to the id ACTUALLY served (drm_served_id), which is the sync the finding asks for - added in an earlier round. * drm: stop routing gates from paying for the availability probe A Major finding I skipped twice, and the file already argued against itself: wayland.rs's own NOTE says re-probing _drm from the async enumeration path blocks the executor long enough to trip 'deadline has elapsed' and spiral into a restart loop -- and then six routing gates called is_available(), which runs query_displays() inline whenever the state is Unknown (cold start, or a NEGATIVE_TTL expiry mid-session). ensure_inited, is_inited, get_displays_and_primary and clear() are exactly the paths the NOTE names. is_available_cached() is a single mutex read: KNOWN-available or not. The six gates use it, which is safe because they are routing decisions, not capability ones -- a cold cache answers 'not DRM' and the caller takes the PipeWire path it would have taken anyway. Switching all seven, which is what the finding literally suggested, would have introduced a worse bug: warm_availability calls query_displays() directly, so is_available() would have had ZERO callers and nothing would ever probe lazily again. A --server that started before the root service would then never see DRM for the rest of its life. get_capturer_for_display keeps the probing form -- it is sync, on the plain video thread, it is the capture-build path where a definitive answer is the point, and it is what makes a cold cache recoverable. * drm: stop leaking the authorized _drm fd into forked children libc::dup() does not copy the close-on-exec flag, so the dup'd _drm socket fd was inherited by every child this process forks. This process is the ROOT service and it does fork synchronously elsewhere (the loginctl active-uid lookup), and that fd is an ALREADY-AUTHORIZED channel to the one thing on the box that hands out scanout dma-bufs. F_DUPFD_CLOEXEC instead. Measured the difference rather than assuming it: dup() leaves FD_CLOEXEC clear, F_DUPFD_CLOEXEC sets it. Also the last two artifact sources without the stub check: - --package + --drm stages the .so straight out of a bundle somebody else produced, with no _assert_so_has_egl. Third source, same exposure as DRMTAP_PREBUILT_DIR, now asserted like the other two. All three artifact paths are covered. - the workflow triggers omitted src/server.rs, src/server/input_service.rs and src/platform/linux.rs, which all carry DRM wiring (warm_availability, the cursor path in run_cursor, the producer start and get_cursor/get_cursor_data), so a PR touching only those skipped the entire drm verification. Added to BOTH mirrored lists and asserted equal (15 == 15). * drm: decide x11 inside the prewarm, with a bounded re-check the one-shot is_x11() gate at the call site misfired during boot: get_display_server() falls back to "x11" while loginctl cannot name the seat0 session yet, so on a wayland host with the service enabled at boot the prewarm was skipped for the life of the service and only ever ran after a manual restart, which is how every deploy happened to exercise it. move the gate inside drm_prewarm and re-ask every 2s for up to 30s. a genuine x11 or headless host exhausts the budget having opened no DrmReader and no drm fd; a wayland boot proceeds as soon as the session reads as wayland. measured on a boot: the skip used to fire 0.8s in while loginctl reported the wayland greeter in that same second, and graphical-session.target only arrived at +5s. * drm: wake idle-disabled displays and settle the topology before the client is promised a list a compositor that idles long enough does not merely blank a panel: it disables the connector, leaving no scanout for any capture backend to read - not drm, not pipewire, not x11. on an unattended box that meant connecting to whatever was still scanning out (on an apple t2, the 60x2170 touch bar strip) with the real panel sitting disabled next to it, or a stale cached list advertising a display with nothing behind it ("waiting for image"). the fix has three parts, and where the wake runs is the load-bearing one: - the root service answers every _drm handshake with a fresh, settled enumeration (drm_enumerate_settled): enumerate, and if a CONNECTED display has no crtc, inject one synthetic 1px pointer round trip over uinput (rate limited to one per 20s, one winner via compare_exchange) and hold the answer until nothing wakeable is left undriven or a 3s deadline passes. rate-limited losers wait for the outcome too while a wake is recent - answering with the pre-wake list is exactly the mid-transition state that produced duplicate, misindexed monitors. connectors a wake could not bring back are latched by connector identity (device:connector) and the latch is self-refuting: an entry later seen scanning out is dropped, so one slow modeset cannot disable the wake for the life of the service, and a dummy plug cannot suppress the wake for a different panel that idles later. - the login path refreshes the cached display list over a live handshake (refresh_displays_for_login) before peer info is built, so the list the client is promised is the post-wake truth and never changes under it seconds later. the publish is generation-checked against concurrent writers; every failure mode keeps the previous cache, so a login can never get harder than before, only truer. - the capture handshake resolves the display index the client chose by connector identity against the handshake list (the service enumerates fresh per connection, so an index alone is only meaningful against the list it came from), fails the build cleanly when that monitor is gone, and no longer republishes its handshake list into the availability cache - that unordered write could clobber a newer settled list with pre-wake data and re-advertise a reordered list under a live session. the display-list read timeout grows to cover the settle budget (DISPLAY_LIST_TIMEOUT_MS), or a wake that needs the full recheck would turn into a spurious handshake timeout on exactly the host it exists for. removing the display cache from the handshake path also retires DRM_CACHE_WARMED; the cache still feeds the topology push and the udev listener. measured on the t2 (amdgpu panel idle-disabled, appletbdrm touch bar still scanning out): connect -> wake fires with undriven=1 -> panel returns in ~330ms -> the same probe answers 2 displays -> the client starts on the panel. with the panel awake: zero wakes. the root service still never maps libEGL/libGLESv2. * drm: close the round-7 review findings - the renumbering probe in the DrmDisplaysChanged handler now reads the pushed list at wire_idx, the slot our monitor held in the service's index space, instead of at the index the client chose. the pushed list shares the handshake list's construction, so probing the client index compared two different index spaces whenever a wake or hotplug had renumbered entries - tearing down a healthy stream or missing a real renumbering. - both message-body reads (cpu frame, cursor pixels) now run under a deadline. only the header read re-checked `stop`, so a producer dying between a header and its body pinned the receive thread forever and every rebuild leaked a thread plus its render context. - the drm cursor cache gets a size ceiling (drm ids are derived from the shape's content, so an animated pointer minted a new key per shape and the map grew for the life of the service; x11 ids come from a small serial set, so the ceiling is gated and the stock build is untouched). - has_non_drm_backed_display reads a two-scalar accessor instead of cloning and geometry-augmenting the whole display list on every cursor tick. - the libdrmtap pin validation moved out of import time into build_libdrmtap_so(), so leftover DRMTAP_* environment variables or a malformed sha cannot fail a stock build that never touches libdrmtap. - reworded a workflow comment whose literal expression marker broke actionlint. * drm: close the round-8 review findings - the .so contract check in the drm workflow runs under strict mode: without set -e the trailing ::notice echo returned 0 and masked the `test "$missing" -eq 0` assertion, so the step passed even with a missing loader symbol or a CPU-only stub. the two extraction pipelines get an explicit rescue so a zero-match grep still reaches the ::error guard that explains WHY instead of dying silently. - the pipewire-fallback geometry guard no longer compares the physical drm size against the portal rect on a single-display host: the rect is the compositor's LOGICAL size, so on a scaled output the two legitimately disagree (2880x1800 vs 1440x900) and the guard rejected the one valid fallback, restart-looping the display instead of degrading. on a single-display host the whole-desktop stream is that display by construction, so only the position has to agree; the size check stays on multi-monitor hosts, where it is what tells one connector apart from the full-desktop rect. * drm: close the round-9 review findings - strict mode on the remaining two assert steps of the drm workflow (the deb-contents assert and the glibc-floor measurement): same masking pattern as the .so contract step fixed last round - without set -e only the last command's status counts and the mid-script checks were decorative. the floor extraction gets an explicit rescue so a no-match grep still reaches the `test -n` reporter. - the security doc states the whole accepted version window (exactly the pinned minor with a patch floor; a NEWER minor is refused too, because the mirrored struct layouts are only verified against the pinned one), and the auditing section carries the command matching its leftover-object comment. - the uinput-missing warning literal lost the embedded space runs a reflow had left in it (it is the sole, once-per-process diagnostic for that failure and it read as a run-on line with gaps). - the geometry-mismatch path in frame() hands the taken buffer back to the recycler before erroring; dropping it made every rebuild cycle re-allocate a scanout-sized buffer. * drm: document the display wake in the threat model the wake is deliberate input injection by privileged code, which is exactly the kind of thing this document exists to state precisely rather than leave to be discovered in the diff: why it must run in the root service (uinput is root-only and the compositor holds drm master), what it can reach (only an already-authorized _drm connection triggers it), how narrow the trigger is (a connected-but-undriven connector, with a self-refuting per-connector memory for the hopeless ones), the rate bound (one wake per 20s process-wide, single winner), the device lifetime (created and destroyed around the emit), and that a host without /dev/uinput loses nothing it had (such a session was already view-only). * drm: close the round-10 review findings - the /dev/dri gate returns the CANONICAL path instead of a bool, and both callers open that value. answering yes/no meant the caller handed the original string to libdrmtap, which re-resolved every symlink component after the check - a check-then-use window, in the root service. this is the whole point of the gate, so it should never have been able to hand back an unresolved path. - `--package --drm` builds the capture library instead of demanding it inside the bundle. no build path puts libdrmtap in a bundle folder (the flutter deb builds it straight into the staged deb), so that check made the flag combination impossible to satisfy. the safety property it stood in for is now asserted directly and better: the staged BINARY must carry the drm dlopen path, so a stock binary can never be packaged under the consent-bypass name. a bundle that does carry a .so keeps its existing EGL assertion, and the variant naming keys on the explicit request rather than on what happened to be staged. - the deb assert step globs into an array and asserts the count: under set -e `ls` aborted before its own `test -n` could report, and several matches produced a multi-line value whose mv failed with an unrelated error. * drm: finish the logical-geometry comparison, and chain a re-raise the pipewire-fallback guard now normalizes BOTH sides to logical before comparing. last round fixed only the single-display case, which left the same defect on the shape that actually has it: on a multi-monitor scaled host the advertised geometry carries the PHYSICAL drm mode plus the compositor scale, while the portal rect is already logical, so a scaled output disagreed with itself (2880x1800 against 1440x900) and a per-connector stream that really was that display was rejected, leaving it advertised offline instead of degrading. the size check itself stays: on a multi-monitor host it is what tells one connector apart from the whole-desktop rect. the failure message reports the logical numbers, the ones actually compared. also chains the libdrmtap read failure with `from err` so the original OSError survives (ruff B904). * drm: fix two review-suggested changes that were wrong, and stop overclaiming in the docs an adversarial sweep over the whole batch, aimed at the failure that kept recurring here (a hazard identified and only some instances fixed), found that two changes made on review advice were themselves defects. both are reverted with the trace written down so they do not get "fixed" again: - the hotplug renumbering probe reads the pushed list at the CLIENT index again, not the service one. `bound_to` is an IDENTITY, (device, crtc_id), so comparing it against a slot is not a cross-index-space comparison; and `swap_available_displays` installs that same list as DRM_STATE two lines later, which IS the client space - display_service re-advertises it, input is mapped through it, the next rebuild reads `expected` out of it. Probing the service index answered a question nothing downstream consumes and went quiet in exactly the case the guard exists for: a stream whose wire_idx differs from its client index kept running while that index came to mean another monitor, so the client rendered monitor A believing it was monitor B and routed every click accordingly. - the pipewire-fallback guard compares raw sizes again. BOTH sides are physical: `Display::width()` on the wayland variant returns `physical_width()`, and `try_fix_logical_size` only repairs the capturable's separate logical_size field. Scaling the drm side therefore compared logical against physical and rejected the valid stream on precisely the scaled outputs it was meant to rescue. The single-display carve-out now needs BOTH sides to be single, since a monitor on a card the service cannot open is missing from the drm list while the compositor still drives it. also from the sweep: - a capture build whose index is out of range of the advertised list now fails instead of falling back to the raw index, which the wake can have grown the service list back past - that bound a second video service to a monitor already being served and recorded its health under the wrong identity. - the security doc no longer claims the privileged process never loads GL. That is true of the DEFAULT path and measured there, but the CPU fallback converts in-process, and a tiled scanout can only be decoded through the GPU, so libdrmtap dlopens libEGL in the calling process when the frame needs it. The doc now says which property belongs to the path and which to the process, and bounds the cases instead of overclaiming. - the wake latch is described honestly: it self-clears when the display is next driven by anything, but nothing retries it, so a transient failure can leave it latched on an unattended host. - the wake's uinput device DECLARES two axes and BTN_LEFT (libinput ignores a device that does not look like a mouse) while EMITTING only the net-zero axis round trip. the doc said one axis and no keys, describing the emit as if it were the declaration. - the drm CI never ran for a change to the root Cargo.toml, where the top-level `drm` feature is defined, or to Cargo.lock, which every `--locked` build here resolves against. both triggers list them now. - the deb assertion checks the packaged BINARY carries the libdrmtap dlopen path, not just that the library was staged beside it. * drm: close the round-13 review findings - the ABI refusal message has a branch for an unverified MINOR. It had only two, so a library NEWER than the pinned minor was told it "predates the split-capture API" - the opposite of its problem, and the kind of message that sends someone looking in the wrong place. the warn line names the accepted minor too. - the libdrm floor no longer claims 18.04 ships 2.4.101: base bionic shipped 2.4.91, which is BELOW the 2.4.95 the GetFB2 API needs, and only the updates/HWE stack clears it. read as "18.04 with updates, or newer". - the drm-build marker scan reads the staged binaries chunked inside a `with`, overlapping by len(marker)-1 so a marker cannot fall across a chunk boundary, instead of pulling a 45 MB librustdesk.so into memory and leaning on refcounting to close the file. verified against a real drm build (found) and an unrelated binary (not found). * drm: close the round-14 review findings - the .so contract and deb assertions no longer pipe into grep. under `set -o pipefail`, `producer | grep -q` reports a FALSE FAILURE once the producer outruns the 64 KB pipe buffer: grep -q exits at the first match, the producer dies on SIGPIPE, and pipefail makes that the pipeline's status - so a library that HAS the symbol is reported as missing it and the step fails on a good build. measured on a real EGL-enabled .so (101 KB of strings, both markers present): the piped form reported both missing. this was introduced by the strictness fix two rounds ago and only passes today because a release-sized .so fits in the buffer. NOTE the obvious repair does not work either - materializing the output and piping the variable keeps the pipe and fails identically (measured), so these now match with bash's own pattern operator and no subprocess at all. verified with positive and negative controls. - warm_availability decides X11 for itself, inside its retry loop, with the UNMEMOISED `scrap::is_x11()`. this is the same one-shot-at -startup bug the pre-warm had, in its sibling call site, left behind when that one was fixed: the check ran during startup, where loginctl cannot yet name the seat0 session and the answer defaults to "x11", 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. the memoised form would have moved the bug rather than fixed it, since it latches its first answer. - the grab_desc SAFETY comment says what the frame protocol actually is instead of promising a release on every return path: traced in the C, a failing grab_desc leaves nothing to release (-EINVAL returns before allocating, a failed inner grab has already cleaned up, and -ENOTSUP releases the frame itself), so releasing on those paths would be a double free. * drm: bound the work an unauthenticated peer can make the root service do the `_drm` socket is world-connectable by design (the unprivileged --server has to reach it), and every accepted peer got a spawn_blocking authorization - which forks `loginctl` whenever the active-uid cache misses - BEFORE any admission bound applied. MAX_DRM_CONNS does not help there: it only counts peers that already passed. So a local uid that will be rejected could still open connections in a loop and keep the shared blocking pool busy, and that pool is shared by every live capture stream, which is exactly the stall the comment above the authorization warns about. add a separate, small in-flight bound around the authorization step, deliberately NOT the same counter as MAX_DRM_CONNS: sharing one would let a rejected flood eat the capacity the real consumer needs. the guard is taken before the spawn and released as soon as the verdict is in, so the slot covers the authorization only. the rejection logs at debug rather than warn for the same reason the existing rejection is silent - anything reachable by any local uid must not be an unbounded log-write primitive. unit-tested like its sibling, including that the pre-auth bound stays the tighter of the two. * drm: reject an out-of-range num_planes on the import side instead of clamping it the incoming descriptor's plane count was clamped to 1..=4 for the validation loop but passed to libdrmtap RAW, so a wire descriptor claiming 7 planes was checked as if it had 4 and then handed over claiming 7. the pinned libdrmtap refuses >4 itself, so this was not an overflow today - but the stated purpose of that block is that the two halves of the split agree about what they will touch BEFORE the C sees it, and that only holds if the count travelling with the descriptor is the count this side bounded. it also stops this half depending on an internal check in a library pinned from another repo. reject and normalize instead, which is what the EXPORT half already does in grab_desc; the two sides now have the same shape. * drm: close the round-17 review findings - the scanout dma-buf fd is duplicated with F_DUPFD_CLOEXEC. `dup(2)` never copies close-on-exec, so this fd was inherited by every child the ROOT service forks (it forks synchronously for the loginctl active-uid lookup) - and what this fd names is the live screen contents. this is the SAME defect already closed on the `_drm` socket fd in ipc/drm.rs; fixing that one and not grepping for the siblings is how this survived. there is exactly one dup in the drm path now and it is this one, verified by grep. measured that F_DUPFD_CLOEXEC sets FD_CLOEXEC and preserves the O_RDONLY access mode the read-only export depends on; SCM_RIGHTS delivery is unaffected since the receiver gets its own descriptor. - Desktop::refresh resolves HOME on the login-Wayland path too, since the drm build now starts a --server as the greeter uid there and a child with no HOME has nowhere to put its config. the compositor variables stay blank deliberately: the drm path talks to the root service and a render node, never to the compositor or the portal, which is why it works at a login screen at all. reasoned, not measured: a current GDM runs its greeter as `gdm-greeter`, which `is_gdm_user` does not match, so that path is not reachable on our hardware - measured there, the greeter server gets a fully populated environment through the branch below. - the glibc-floor step globs into an array and asserts the count, like its sibling assert step. that sibling was fixed two rounds ago and this one was left behind. * drm: put the display wake behind its own compile gate and a runtime option everything else in this backend READS: it captures a scanout. the wake WRITES, injecting one synthetic pointer event from the root service into the user's session. that is a different kind of operation and it should be switchable on its own, at both levels. - compile: a `drm-wake` feature on top of `drm`. every wake-only item is gated and drm_enumerate_settled has two definitions, so `--features drm` builds the same capture path with no wake code in the binary. verified on a RELEASE artifact with both controls: the drm markers are present (Started drm ipc server) and the wake string is gone. the unattended deb passes drm-wake, so answering an objection is one word in build.py rather than a revert. - runtime: `enable-drm-display-wake`, server-side, the same shape rustdesk already uses for the closest thing it does to this (keep-awake-during-incoming-sessions, which PREVENTS sleep where this RECOVERS from it, and is acquired only once a connection exists, which is too late for a host that cannot be reached). the `enable-` prefix is load-bearing: option2bool reads an absent value as ON, and a host whose screen went dark is the case the unattended package exists for. set it to "N" and the service stays read-only with respect to input. the key is declared in this file rather than in hbb_common's `keys` module, where rustdesk's own option constants live: hbb_common is a submodule of a repo we do not control, so a constant there could only land after an upstream change plus a submodule bump. the option system reads by string, so registration is not required; the cost is that the key is set in the config file rather than the settings UI, which is how an unattended host is configured anyway. * drm: enumerate /dev/dri by path instead of trusting one auto-detected card when `list_devices` gives us nothing to work with, the fallback was a single auto-detected reader. that is the wrong unit of enumeration on a multi-card host, and the reason is worth keeping: libdrmtap's auto-detect picks a card that is SCANNING OUT, so when the interesting display is asleep it picks a DIFFERENT card and we enumerate only that one. the asleep display is then invisible - not as a display, and not as an undriven connector either, which is what the wake keys on. measured on the t2 with the panel idle-disabled, through a direct libdrmtap call: auto-detect succeeds and binds card0, the touch bar, because the touch bar is what is still scanning out; the 2880x1800 panel on card2 is invisible to that reader, while opening card2 by explicit path in the same instant reports `eDP-1 crtc=0 active=0` exactly as needed. so walk /dev/dri/card* and ask each, with auto-detect demoted to a last resort for the case where no card opens by path. this path is reached only when list_devices is unavailable (a pre-0.4.15 .so) or opened nothing, so it costs nothing on the normal path - it is defensive, not a fix for anything observed with the pinned library. the enumeration result is logged UNCONDITIONALLY, including the empty case, because a silent "found nothing" gives no way to tell an empty host from a failed enumeration. * docs: state the per-frame reauthz and the wake's one-shot bound Two things the security doc left implicit, both measured on 2026-07-31. The `_drm` authorization is described as per-connection, which undersells it. DRM/KMS capture is not session-scoped - it grabs the physical scanout of a CRTC no matter which session owns the display - so the check is re-run on every frame, and when a user logs in at a greeter the greeter's stream is closed rather than continued. That is the property that stops an outgoing greeter process from capturing the screen of the user who just logged in, and it is worth stating where a reader is looking for exactly that confinement. And the wake section never said what happens after the wake. It resets the compositor's idle timer; it does not hold the display on. Left alone, the connector idles off again one full idle period later: 30.3 s at a GDM greeter, 70.3 s in a user session with idle-delay=60. Saying so makes the existing "useless as a way to keep a screen lit" clause concrete, and points at the component whose job that actually is. * drm: ship the wake in the CI deb, and assert the artifact on both package paths Three findings from the round on the wake-gate commits, all the same shape: the gate made "what was asked for" and "what was produced" diverge, and two places still trusted the first. CI built the unattended-wayland deb with `--features ...,drm` and then packaged it with `--skip-cargo`. build.py appends `drm-wake` for `--drm`, but skipping cargo means whatever that explicit line compiled is what ships, so the deb had no wake code in it at all while being named and documented as the variant that has it. The feature list has to be complete on the line that actually builds. The marker assertion that catches exactly this class only guarded one of the two packaging paths. `build_deb_from_folder` asserts that the staged binary carries the libdrmtap dlopen path before it takes the unattended-wayland name; the flutter path did not, and `--skip-cargo` reaches that one. A stock binary could therefore be packaged under a name that conflicts with and replaces the stock package, and then never capture. Hoisted the check to module level and called it from both, before the bundle is renamed. And the security doc described the synthetic input injection as an unconditional property of a drm build. It is behind its own compile feature and a runtime option, which is exactly what an operator auditing the deb needs to know. * drm: stop a delivered frame from erasing the two verdicts it says nothing about A deep review pass over the whole branch, run because a maintainer once found two bugs here that nineteen rounds of an automated reviewer had missed. Three findings, two of them the same root cause, all confirmed by re-reading the code. The first frame of a session dropped the display's whole health entry. That is right for the zero-frame streak, which is exactly the verdict a delivered frame refutes, and wrong for the other two: - `last_build`/`rapid_builds` exist for a display that delivers a first frame and then fails downstream every cycle. Wiping the cadence on that frame meant the flap guard could never reach RAPID_REBUILD_MAX in the one case its own doc comment describes. It was a guard that could not fire. - `prefer_cpu` records which GPU exports a monitor, a property of the host, and is documented as following the monitor for the process run. Erasing it on the first frame it made possible meant every rebuild re-paid a dead dma-buf session: fail, learn, take the CPU path, forget, fail again. It never demotes, because the CPU session clears the streak each time, so it repeats for the process lifetime. Worse, the bit is set on the recv thread and was deleted on the encoder thread, so a convert failure racing a queued frame could destroy it inside the very session that learned it. So reset only the streak. Only a topology change, where the GPU mapping really can have changed, may still clear the convert verdict. Second, `get_primary_index` was a second, weaker copy of the connector-to-output matcher: name-only, with neither the unique-resolution step nor the layout-order fallback the augmentation grew. On a compositor whose names do not normalize to the DRM names it answered 0 while the geometry augmentation had matched that display to a different output, so the advertised primary and the advertised geometry disagreed. It now asks the same assignment, which makes them agree by construction. Third, packaging asserted half of what the deb claims. `assert_staged_binary_is_drm` looked for the libdrmtap dlopen path, which `--features drm` alone also carries, so a bundle built without `drm-wake` could still be named and documented as the variant that wakes an idle-disabled display; it now requires the wake marker too. And nothing anywhere checked that the libdrmtap being shipped is one the runtime would accept: `abi_accepted` is the only validation of the pinned version and it runs at dlopen time on the user's machine, so the pin and the gate could drift and every existing assertion would still pass -- EGL markers say nothing about the version, the CI symbol contract never calls drmtap_version(), and the deb regex matches any version. Staging now applies the gate parsed out of the Rust, so a green build cannot produce a deb whose capture can never start. * drm: fix the ABI cross-check's path, and stop panicking on a failed spawn The ABI cross-check added in the previous commit could never run: both callers of stage_libdrmtap_into_deb chdir into flutter/ first, and the check opened drmtap_dl.rs by a path relative to the cwd, so every --drm packaging run died with FileNotFoundError. CI caught it. It is anchored on __file__ now, and read through a context manager. Worth naming why the test missed it: the check was exercised from the repository root, which is the one directory where the bug is invisible. A control that does not reproduce the call site's conditions is not a control. Three more, all the same class the previous commit was already fixing - a hazard closed at one site and left at its siblings: - `std::thread::spawn` panics when the thread cannot be created, and the panic unwinds into whoever called it. The two hardened workers used Builder; the five remaining DRM threads did not. The startup ones now log and degrade (a lost pre-warm costs one cold probe, a lost udev listener costs the mid-session push, a lost warm costs the first session), and the two per-session ones live in functions that already return ResultType, so they fail that one connection cleanly instead of unwinding through the handler. - The wire descriptor's `num_planes` was clamped to 1..=4 here while `drm_render::convert` rejects an out-of-range count on purpose, so that the count the C reads is the count this side validated. Clamping made that reject unreachable: a descriptor claiming 7 planes arrived as 4 and passed. The two guards were added by different review rounds and had been quietly cancelling each other. The raw value is passed through now, leaving one validation site, next to the code that dereferences it. - A SAFETY comment claimed the cursor is released only on success. It is released on every path after a successful get_cursor; only a failed get_cursor returns without releasing, because then there is nothing to release. The release protocol is the reason that block is unsafe, so the comment describing it has to be right. * drm: convert the last panicking spawn, and resolve geometry outside the lock The spawn conversion in the previous commit missed one. `query_displays` still used `std::thread::spawn`, which panics when a thread cannot be created, and it is reached from both `get_capturer_info` and `warm_availability` - so the panic would land on the capture-build path rather than being reported as the failed probe every caller already handles. There are now none left in the two DRM files. Worth writing down how it survived a pass whose whole purpose was to find it: the previous commit enumerated the siblings with a grep piped through `head`, there were eleven matches, and `head` printed ten. The one it cut is the one that was missed. Same shape as a build log read through `tail` and a `find` given `-xdev`: the tool truncated the survey and the survey looked complete. When enumerating sites for a class fix, do not pipe the enumeration. Also, `get_capturer_for_display` resolved the advertised DRM geometry while holding the `CAP_DISPLAY_INFO` read guard. That lookup runs a compositor output roundtrip, and `clear()` takes the write guard on every capturer teardown - which is what is happening when a display is demoted or flapping, i.e. exactly when this path runs. The value does not depend on anything inside the guard, so it is resolved before taking it. And the security doc listed the unattended package's `Conflicts`/`Replaces` but not its `Provides: rustdesk`, which is the field that lets a third-party package depending on `rustdesk` be satisfied by the consent-free variant. An operator auditing that metadata needs all three. * drm: test that a delivered frame keeps the cadence and the convert verdict The guard this locks in could never fire before: a delivered frame dropped the whole DisplayHealth entry, which took last_build/rapid_builds with it, and those exist precisely for a display that delivers a first frame and then fails downstream every cycle. prefer_cpu went the same way, erased by the first frame it had made possible. The test drives the real frame() path through the existing harness rather than simulating the bookkeeping, and it was checked against the old behaviour: with the entry removed again it fails on "the entry must SURVIVE a delivered frame". A test that has not been seen failing is not evidence. * drm: bound the two waits a peer could hold open in the root service A review pass over the privileged side, reading src/ipc/drm.rs as a local unprivileged attacker. Two findings, both confirmed by tracing every link. The wire had a deadline in one direction only. Every read has been bounded since the beginning, and next_raw_into even carries the argument for it: a peer that writes a header and then stops pins the other end forever on a readiness wait. The write side had no deadline at all. That asymmetry costs more here, because the parked task is in the root service: a peer that simply stops reading - a kill -STOP on its own --server, a ptrace stop, a frozen cgroup - leaves the send blocked inside the forward loop, so the loop top is never reached again. The credit stall, the per-frame reauthorization and the topology-generation check all live at that loop top, and the connection slot, the worker thread and its DRM context stay pinned until the peer chooses to resume. drm_write_all is the single funnel for both directions, so one deadline there covers every send; the consumer's frame-ack write had the same shape and gets the same bound. And drain_frame_acks looped until WouldBlock, which is a promise the peer gets to keep. It is synchronous on the single-threaded _drm runtime, so a peer that writes a continuous stream instead of one ack byte per frame keeps the receive queue non-empty, never yields, and pins that thread at 100% CPU - starving every other stream on it, which on a multi-monitor client means one connection wedging its own siblings. Capped per call, with an early return once the credit budget is full; anything left stays queued for the next pass. Three comments were describing a mechanism that no longer exists. Two still said a delivered frame drops the whole health entry, which stopped being true when that was narrowed to zeroing the streak; the third, written in that same change, pointed at drm_clear_prefer_cpu, a function deleted several commits earlier. The convert verdict having no clearing site is correct and now says why: it is keyed by connector identity, so a monitor that moves to another GPU arrives under a new key and starts clean. Also, the new regression test held the process-wide health mutex across its assertions, so the one failure it exists to report would have poisoned that mutex and buried itself under unrelated PoisonErrors in its sibling tests. It copies the record out and releases the guard first, as the module's own helper does. * drm: clear the stale _drm entry by fd, and fix three comments that argue backwards new_drm_listener cleared the stale socket with std::fs::remove_file, which is unlink(2). Against a directory-typed squatter that returns EISDIR and leaves the entry in place, and endpoint.incoming() then fails EADDRINUSE, so DRM capture falls back to the portal for the rest of the boot over an entry we could have removed. The _service listener has never had that hole: it removes entries through a no-follow fd on the parent directory, fstatting the entry first and choosing AT_REMOVEDIR when it needs to. That helper now takes a path instead of a postfix, so the _drm listener - which deliberately stays outside hbb_common's postfix machinery - can use the same one on the directory it just hardened. The precondition is narrow (an unprivileged process has to win the creation race before the root service first hardens the dir on a fresh boot), which is why the failure is a warn and not a bail. Three comments stated their reason backwards or more strongly than the code supports. None of them changes behaviour; all three would send the next reader to verify the wrong thing. The wake's 20 s rate limit was justified as being short enough to be useless as a way to keep a screen lit. That is inverted: a shorter gap would make relighting easier, not harder, and 20 s is below every idle period we have measured (30.3 s at a greeter, 70.3 s in a session). What actually bounds it is that the wake is one-shot, which the next sentence of the same doc already says. Fixed at both sites, the constant and the security doc. The doc block above drm_enumerate_settled reads as one paragraph but spans a cfg split, so its shared contract and the wake-less specialisation looked like one statement about the arm below it. Marked explicitly. And get_primary_index claimed its answer agrees with the advertised geometry by construction, which is true only where augment_with_wayland_geometry runs the same assignment - it declines below two connectors or two outputs, and in that band the two functions run different code. The answer is still never worse than the documented fallback there, and now the comment says which. * drm: test that the fd-based removal clears a directory squatter The regression this pins is the one the previous commit fixed: a stale entry in the IPC parent directory is not necessarily a socket, and unlink(2) refuses a directory. The test asserts remove_file fails on it FIRST, so a passing run cannot be vacuous, and it checks the second call succeeds too, since this runs before every bind. Confirmed red against a neutralised helper before being kept. * drm: fix what the previous commit's own comments got wrong A review pass over 08d311d60 - the commit whose stated job was correcting three comments that argued backwards - found that four of its replacements were wrong in turn. Two independent passes agreed on each. This is the correction. The 20 s gap paragraph was never attached to the constant. It is the first paragraph of a doc block that runs on to OPTION_ENABLE_DRM_DISPLAY_WAKE, so it documented a config-key string, while DRM_WAKE_MIN_GAP three lines below had no doc at all. That misplacement predates the previous commit; expanding the paragraph from one line to five without noticing does not. Moved onto the constant. Its content was also wrong for the second time. Saying the limit is not what stops a screen being held on was right; naming the one-shot property as the thing that does bound it was not. One-shot cannot bound a repeated relight when the permitted repeat interval is shorter than the idle period, which is exactly what the sentence before it establishes: 20 s against a measured 30 s. An authorized peer that keeps reconnecting can have the panel relit shortly after each idle-off, and what makes that acceptable is the authorization itself - root or the active session's own uid, who can hold their screen on with systemd-inhibit and need nothing from us. Both the constant and the security doc now say that, and the constant carries a note not to write the old claim a third time. The shared contract of drm_enumerate_settled sat on the arm the shipped build compiles out. build.py --drm adds drm-wake, so a maintainer opening the real function found it undocumented while a doc comment marked "shared" hung off its dead twin. A doc comment cannot attach to two cfg arms, so the shared part is now a plain comment above both and each arm keeps a short doc of its own. get_primary_index claimed a sole compositor output is matched to the lowest connector. It is not: pass 1 matches by normalised name and by unique resolution before any layout-order fallback, so the answer in that band can be any index. The conclusion survives - a name match is better evidence than a blind 0 - but the reason given for it was false, and the reason is what the next reader uses. And the directory case is narrower than it was written. AT_REMOVEDIR is rmdir, so what the previous commit closes is the EMPTY squatter; a non-empty one still returns ENOTEMPTY and still blocks the bind. Left that way on purpose - the cure would be root recursively deleting a tree an unprivileged process planted in a world-writable directory - and now stated at all three sites plus pinned by the test, which also stops claiming to cover the call site it does not reach. * drm: say less in these comments, since saying more keeps being wrong Third pass over the same comments, and the third set of errors in them. The pattern is not that any one sentence was careless, it is that every additional explanatory sentence is another falsifiable claim, and the ones that keep failing are the ones that reach past what the file can support. So this is mostly deletion: net fifteen lines fewer. The "SHARED CONTRACT" header was wrong about its own first paragraph. That paragraph describes waking, waiting and a rate-limit race, none of which the wake-less arm does - and the previous commit went further and pointed the wake-less arm's own doc at it, so that arm now claimed to do the thing the very next line said it does not. Only the second paragraph, on why an idle-disabled output is the trigger, is genuinely common to both. That stays above the pair as a plain comment; the wake behaviour moves onto the wake arm, where it is true. DRM_WAKE_MIN_GAP no longer argues about why unbounded relighting is acceptable. It named the wrong actor: the _drm peer is always our own unprivileged --server, while the party whose reconnects drive the relight is the remote client, which is neither root nor the local uid and cannot inhibit anything. The constant now states what it bounds and what it does not, and stops there. The security document makes the acceptability argument instead, and makes it about the right party: a peer already authorized to watch that screen gets it lit, which is visible to a person standing there, not additional access. Two narrower ones. The helper said a non-empty squatter yields a named error "instead of" EADDRINUSE; the caller gets both, and the sibling comment in the listener already said "ahead of", so the same commit disagreed with itself. And get_primary_index claimed the two functions disagree across the whole band where augmentation declines, which is false for zero outputs and for a single connector - it now names the one case that matters. Not touched, and pre-existing: MAX_DRM_CONNS's doc block has the same wrong-item defect (it opens on a function and ends on the cap), and drm_enumerate_all_displays runs two paragraphs together. Both predate this branch's comment work and neither belongs in a commit about it. * drm: give the send deadline one budget for the whole write, not one per wait The earlier commit put the timeout inside the loop, so the budget restarted on every iteration. A peer that accepts a byte just inside each window, or that keeps the socket flapping back to WouldBlock, re-arms it forever and the root task stays parked exactly as it did before - which is the stall the constant's own doc says it bounds. The diagnosis was right and the fix did not implement it. Both send paths now take one deadline before the loop and wait with timeout_at. Swept the rest of the file for the same shape. The credit wait re-arms a 1 s poll on purpose and is fine: its total bound is CREDIT_STALL, measured at the loop top from credit_since, and its comment already says the deadline is enforced there and not in the poll. That is the pattern the write path was missing. The read paths are single-shot bounded, not loops. Not covered by a test. Reproducing it needs a peer that accepts a little data just inside each window, so the scenario runs longer than the 5 s budget itself and a no-progress peer - the case a simple test would build - times out correctly under both the old code and the new. * drm: stop claiming the wake-less build cannot inject input It can. Dropping drm-wake removes injection from the CAPTURE path and nothing else: start_os_service calls start_uinput_service unconditionally, with no feature gate, so the root service runs RustDesk's keyboard and mouse uinput backends on every build, drm or not. That is how remote control works on Wayland and is not ours to change - but a maintainer auditing "is the injection path present in this build?" was being told no by a comment in the file most likely to be read for that question. The line now says what is actually true of the capture path and points at the ungated call, so the next reader is not sent to verify the wrong claim. The sentence is inherited: it came in with 2648ad0a2 and survived two review rounds because both were reading the comments I had just CHANGED, and this one I only re-wrapped. Re-wrapping is re-asserting. Also narrowed the wake arm's "the wait applies to every handshake that saw an undriven display": four early returns skip it - option off, nothing wakeable, no uinput, no recent wake to settle - and the same block asserts the first of them four lines later, so the paragraph contradicted itself. And "the trigger" in the shared block lost its antecedent when the wake paragraph moved onto the wake arm; it is "the signal" now, which is true for both arms. * drm: put two doc blocks on the items they describe Both pre-existing, both found by walking every doc run in the file down to the item it attaches to rather than by reading prose. handle_drm_conn's description was stranded: the block opened on the function and ended on the connection cap, so it attached to MAX_DRM_CONNS while the function itself had no doc at all. Moved the function's paragraph onto the function; the cap keeps its own. And drm_enumerate_all_displays ran its enumeration paragraph and its return-value paragraph together with no separator, so they read as one. Blank doc line between them. No text changed in either case - this is placement only. * drm: pin the send deadline with a test, and close five review findings The send deadline had no test, and I had written down that it could not have one: a peer that never reads times out correctly under the broken per-wait form too, so the obvious test proves nothing. That is true and it is not the whole answer. A peer that DRIPS separates them, and the first version I wrote still did not - draining a kilobyte at a time never makes the socket writable again, because Linux asserts POLLOUT on a stream socket only once a decent fraction of the send buffer is free, so the sender saw one long readiness wait and both forms timed out identically. At 64 KiB the socket really does re-arm and the two diverge. Measured both ways: the test passes in five seconds against the fix and fails at twenty against the per-wait form, with the message it exists to print. The chunk size is documented in the test for exactly that reason. Four more, all verified against the code before touching it: grab()'s SAFETY block claimed the frame is "released on every path". The ret < 0 arm returns without releasing, because a failed grab_mapped leaves nothing to release. Its two siblings, grab_desc and cursor, already state the distinction precisely; this was the loose copy, and the release protocol is the reason the block is unsafe in the first place. drmtap_dl.rs still said minor bumps are additive and compatible. abi_accepted requires an exact minor match and the block below it explains why, so the file argued both sides and the stale half is an invitation to widen the gate. grab_desc validated width, height and plane count but not pitch or offset, while the converter bounds pitch * height + offset per plane. Same bound on the export side now, so both halves refuse the same descriptors - the principle grab() already states. No pixel access happens there, so this is not an out-of-bounds fix; it keeps a bogus pitch off the wire and puts the rejection on the side that can name the device. And the deb staging interpolated so_path unquoted, which breaks on a path with a space (DRMTAP_PREBUILT_DIR is user-supplied). Also covers the regular-file case through the new removal helper - the stale socket every restart hits, which the existing file test reaches by another path. * build: quote the rest of the path interpolations, not just the two that were named The previous commit quoted so_path and stopped there, which left the six shell commands that build libdrmtap interpolating src and build_dir bare. Both derive from repo_root, which is built from __file__, so a checkout under a path with a space splits the argument and git init, git remote add, git fetch, git checkout, meson setup and meson compile all fail with an error that says nothing about the real cause. Same defect, same fix, and quoting one pair while leaving its siblings is the shape a reviewer finds next. * fix(drm): close the review items on the capture backend Guard the producer thread, surface a swallowed spawn error, stop the CI feature list from drifting from build.py, and four smaller ones. Should-fix: - `start_os_service` started the DRM producer with a bare `thread::spawn`, the one spawn in this feature that was not built with `thread::Builder`. `spawn` panics if the thread cannot be created (EAGAIN under a thread or memory limit), and that panic unwinds out of `start_os_service` and takes the root service with it -- for a feature whose failure should only cost DRM capture. Builder + warn, like the other four. - `refresh_available_async` dropped the spawn result on the floor. There is no wedge (the single-flight guard moved into the closure and is dropped with it), but a refresh that can never start was invisible: the cached verdict just keeps being served past its TTL. The sibling spawn already logged; now both do. - The drm workflow hardcoded the cargo feature list because it packages with `--skip-cargo`, so `get_features()` in build.py and the CI line were two definitions of the same thing and only the drm/drm-wake half was asserted afterwards. Adds `build.py --print-features`, which prints the list those flags select and exits, so CI asks instead of repeating; the same flags now drive the compile and the packaging. The step asserts the answer really is a drm build before handing it to cargo, matching whole comma-separated tokens so a future feature merely containing "drm" cannot satisfy it. Smaller: - The ENOTSUP fallback in `drm_capture_worker` switched to the CPU path without clearing `stalled`, so stalls charged to the dma-buf path could trip MAX_STALLED early and close a connection the fallback was about to serve. - `FrameSlot` kept one recycled buffer and claimed at most one is idle at a time, which does not hold: the receive path supersedes an unconsumed frame while the encoder returns its borrow, and those two writers do not even share a lock, since the receive path takes a buffer and publishes in two separate acquisitions. The later write freed a scanout-sized allocation the recycler exists to keep. Two slots is the exact bound for three in-flight buffers. The existing test passed against this, so the new one counts the offers rather than asking whether any came back. - `get_cursor`/`get_cursor_data` use the memoised `is_x11()` while the capture path deliberately uses the unmemoised `scrap::is_x11()`. That is the right trade at cursor cadence, since the unmemoised form forks `loginctl` per call -- say so, because the surrounding code argues the opposite for its own callers. * docs(drm): cut the changelog prose out of the comments Removes passages that document this patch's own revision history rather than the code, including the four quoted in review. Deletions and one misplaced comment moved to the field it describes; no comment was reworded, so nothing here can state something new. - `drm_capturer.rs`: the `drm_clear_prefer_cpu` parenthetical (that function does not exist), "same mistake, same shape, as the two flags before it" (it names no identifier, and both sites it gestures at carry their own hazard comments), and "the comment was right and the code used the probing accessor anyway". - `drmtap_dl.rs`: "this test replaces one that asserted the opposite", and "that sentence used to live here" -- the instruction not to widen the gate on the strength of "minor bumps are additive" stays, since that is a live constraint rather than history. - `platform/linux.rs`: the "NOT REPRODUCIBLE ON OUR HARDWARE" provenance label. What it introduced survives and is the better form of the same warning: on the test host `is_gdm_user` does not match `gdm-greeter`, so that branch is dead there and the code is for display managers whose greeter user does match. - `ipc/drm.rs`: "and that sentence has already been wrong here twice". The warning it trailed stays, because a shorter gap really would make relighting easier and the constant should not be described as bounding how long a screen stays lit. - `build.py`: "the answer to an objection is one word, not a revert". Also moves the comment describing `cur` off `display`, where a field reorder had left it sitting above that field's own comment. Most of the remaining density is mechanism, measurement or a hazard, and is left alone: the pipe/SIGPIPE analysis, the physical-vs-logical rect comparison, the `wire_idx` vs `display` argument, the wake measurements (REL_X alone did not wake the panel; the device bind window), the F_DUPFD_CLOEXEC privilege-leak argument, and the SAFETY blocks. * docs(drm): condense the capture comments from 35% of lines to 6% The five DRM files were 2319 comment lines against 4181 of code. The rest of this repository runs at 3%, so they were roughly twelve times the surrounding density, and that was the fair reading of the review: the volume itself is what makes an 8k-line addition hard to review. They are now 302 lines. What went is rationale: alternatives considered and rejected, arguments for why a design is acceptable, restatements of what the next line of code plainly says, and the same fact repeated at several sites. What stayed is what a reader cannot recover from the code, kept to one or two lines each: - every SAFETY comment on an unsafe block (none was dropped) - ownership and release contracts with the libdrmtap C API, including which grabs own a frame and which must not release it - ordering requirements: announce a pending refresh before claiming the single-flight slot, take the busy flag before the spawn rather than inside the closure, never hold DRM_STATE while taking a per-display map - the flow-control protocol, both ends of it - wire-format and units conventions, and the cmsghdr alignment the control-buffer type exists to provide - measured facts, reduced to the measurement: which synthetic events wake an idle panel and which do not, and the device bind window - hazards on the world-connectable listener, including why the rejection paths log at debug or not at all No code changed: with comments and blank lines stripped, all five files are byte-identical to their previous contents. Tests are 111 in the rustdesk crate and 20 in scrap. * docs(drm): restore the wire_idx argument on the hotplug guard The condensation cut this one too far. Within minutes of the shortened version going up for review, a reviewer read the remaining line and proposed changing the probe from `display` to `wire_idx` -- which is the change that was already tried here and was wrong. So the argument is not rationale prose, it is what stops a plausible and incorrect edit to a guard in the capture path, and it goes back in at six lines: `bound_to` is an identity rather than a position, the swap below installs this list as the client-space DRM_STATE, and probing `wire_idx` would go quiet in precisely the case the guard exists to catch. * docs(drm): correct what an empty render_node means on the wire The condensed doc said "Empty = auto-select", which is false on the host that field exists for. `drm_capture_worker` computes `ambiguous_gpu = render_node.is_empty() && render_node_count() > 1` and folds it into `force_cpu`, so an unnamed exporter on a machine with several render nodes takes the CPU path rather than auto-selecting. It auto-selects only where there is a single node. * docs(drm): fix comment claims that do not match the code An audit that verified every comment claim against the CODE (rather than against the pre-condensation text, which is what the earlier pass did) found twenty that were false or unqualified. Some came from the condensation dropping a qualifier; several predate it. The ones that mattered most: - `drm_render.rs` said libEGL/libGLESv2 are loaded "never in the privileged root service". That is true of the split path only: the CPU fallback calls `drmtap_grab_mapped`, whose auto-process step reaches `drmtap_gpu_egl_convert` in the CALLING process. `DRM_CAPTURE_SECURITY.md` already documents this precisely, and `drm_reader.rs` already said "on this path"; this one comment had lost the qualifier. - "A miss is fail-closed" on the per-frame reauthorization: true for a non-root peer only, since `drm_peer_authorized` returns true for uid 0 before it compares against the active session. - The cursor body check was described as a no-op because the hidden sentinel supposedly arrives 0x0 with an empty body. It arrives 1x1 with four bytes, so the check is live. - "EVERY write to DRM_STATE goes through here": the TTL restamp writes directly, and the comment on that arm says so. - `open(crtc=0)` was described as selecting the "primary" CRTC; libdrmtap picks the first CRTC with a valid mode, and in that library "primary" names a plane. - `list_devices() == None` was described as leaving the caller on single-device auto-detect; the caller scans /dev/dri/card* itself. - The framing note claimed the whole channel is length-prefixed; the reverse-direction frame acks are bare bytes. Also corrects `buffer_id`, which was documented as the producer's stable pool key: it is fb_id tagged with a per-connection epoch and no consumer reads it today. No behaviour changes. One executable line is touched: the message string of a unit-test `assert!` that asserted the auto-select claim being corrected here. * docs(drm): fix the second primary-CRTC occurrence the audit flagged Same correction as the enumeration-side comment: libdrmtap auto-selects the first CRTC with a valid mode, and primary names a plane there. The audit had flagged both sites and only one was fixed. * feat(drm): move the libdrmtap pin to 0.5.2 and the ABI gate with it libdrmtap 0.5.2 is now on rustdesk-org, so the pin can move. It fixes the padded-framebuffer read: a scanout whose pitch exceeds width*bpp was decoded at the wrong stride, which is why the Touch Bar strip on an Apple T2 produced no image and was listed as a known limitation. The three parts have to land together, and build.py enforces it: the staged .so is cross-checked against the ABI constants parsed out of drmtap_dl.rs, so a pin without the gate (or a gate without the pin) fails the build rather than producing a deb whose capture can never start. - pin: cbc5e6af5 (0.4.15) -> 653de8c (0.5.2), in build.py, which is the single source of truth, plus the informational version comment in libs/scrap/Cargo.toml. - gate: DRMTAP_ABI_MINOR 4 -> 5 and the patch floor (4, 10) -> (5, 0). 0.4.x is now refused even though it carries the whole split API, because of the stride bug above. - the newer-minor rejection test now derives its cases from DRMTAP_ABI_MINOR rather than hardcoding 5, so the next bump cannot leave it asserting that the newly verified minor must be refused. That is exactly what the hardcoded list would have done here. - DRM_CAPTURE_SECURITY.md: the vetted window is now 0.5.x with x >= 0. Verified: the build fetches 653de8c by sha and meson produces libdrmtap.so.0.5.2, which the runtime gate accepts. Tests 111 in the rustdesk crate, 20 in scrap. * fix(drm): refuse --drm on the packaging paths that cannot honour it Blocking finding from review. `get_features()` gated only on `windows or osx`, but Linux has four packaging branches and only the deb one is drm-aware. On a host with pacman, yum or zypper, `--drm` compiled in `drm,drm-wake` and then packaged through a path that does not bundle libdrmtap, does not rename, adds no Conflicts/Provides and never runs `assert_staged_binary_is_drm()` -- emitting a package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput injection. The distinctly named package is the informed consent this feature rests on, so those branches now refuse the flag instead. `linux_packaging_branch()` mirrors the elif chain in main() and is the single place that decides, so the check cannot silently disagree with the branch actually taken. Also from the same review: - the bare-soname dlopen fallback is no longer offered when running as root. It exists so an unpackaged development build can load a locally built .so, but it was also the one place where which file happens to be on the ld.so path decided what gets mapped into the CAP_SYS_ADMIN process. The packaged service finds the absolute path first regardless, and a root process that reaches the fallback has no bundled library at all, which is the PipeWire-fallback case rather than a reason to search. - `rm -f {so}` is quoted, like the neighbouring `cp` already was. - `Cargo.lock` is dropped as a CI path trigger. Measured over the last 100 commits it alone would have fired this workflow 13 times and the pair 24 times, each about two job-hours of vcpkg + flutter release build, almost always for a dependency the drm path never touches. - `abi_gate_rejects_a_library_from_before_the_split` no longer implies the patch floor is what refuses those versions; the minor mismatch is. The floor is vacuous by construction while it sits at patch 0 of the verified minor, so a second test asserts exactly that and turns into a tripwire the next time a floor lands mid-minor, as (4, 10) did. --- .github/workflows/drm-capture.yml | 449 +++++++ .gitignore | 4 +- Cargo.toml | 7 + build.py | 458 ++++++- docs/DRM_CAPTURE_SECURITY.md | 255 ++++ libs/scrap/Cargo.toml | 10 + libs/scrap/src/common/drm_reader.rs | 477 +++++++ libs/scrap/src/common/drm_render.rs | 184 +++ libs/scrap/src/common/drmtap_dl.rs | 410 ++++++ libs/scrap/src/common/mod.rs | 6 + src/ipc.rs | 63 + src/ipc/auth.rs | 11 + src/ipc/drm.rs | 1799 +++++++++++++++++++++++++++ src/ipc/fs.rs | 90 +- src/platform/linux.rs | 163 +++ src/server.rs | 21 + src/server/display_service.rs | 44 + src/server/drm_capturer.rs | 1670 +++++++++++++++++++++++++ src/server/input_service.rs | 49 +- src/server/wayland.rs | 226 ++++ 20 files changed, 6384 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/drm-capture.yml create mode 100644 docs/DRM_CAPTURE_SECURITY.md create mode 100644 libs/scrap/src/common/drm_reader.rs create mode 100644 libs/scrap/src/common/drm_render.rs create mode 100644 libs/scrap/src/common/drmtap_dl.rs create mode 100644 src/ipc/drm.rs create mode 100644 src/server/drm_capturer.rs diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml new file mode 100644 index 00000000000..2efc6eabd97 --- /dev/null +++ b/.github/workflows/drm-capture.yml @@ -0,0 +1,449 @@ +name: DRM capture (opt-in drm feature) + +# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact +# up/download used by the deb job authenticates with the runtime token, not this one. Declared at +# the workflow level so the reusable bridge workflow called below inherits the same bound. +permissions: + contents: read + +# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to +# record that a given commit on master was verified. +concurrency: + group: drm-capture-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows +# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related +# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. +# +# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is +# the drm-off configuration and that stays the primary verified one. + +on: + workflow_dispatch: + pull_request: + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + push: + branches: + - master + # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push + # that touches only the missing paths (a squash merge, a direct push) skips re-verification. + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + +env: + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + FLUTTER_VERSION: "3.24.5" + +jobs: + drm-tests: + name: drm unit tests (linux) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y \ + clang cmake curl gcc git g++ \ + libpam0g-dev libasound2-dev libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name + # filter would skip the sibling asserts that also matter in this configuration, notably the one + # bounding `size_of::()`, which the new DmabufDesc variant grows. + # The two skips are the same ones the stock CI applies: both need a real display server and fail + # on a headless runner regardless of this feature. + - name: Run rustdesk crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ + --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state + + # The capture backend itself lives in the scrap crate, so its unit tests are a separate + # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. + - name: Run scrap crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib + + libdrmtap: + name: libdrmtap pin, build and .so contract + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install libdrmtap build deps + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ + libegl1-mesa-dev libgles2-mesa-dev + + # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or + # moved pin fails here rather than in a release job. + - name: Fetch the pinned libdrmtap and build the .so + shell: bash + run: | + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + so = b.build_libdrmtap_so() + print(f"::notice::built {so}") + open("so_path", "w").write(so) + PY + + # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or + # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, + # so assert here that the object we would ship really carries EGL and really exports every symbol + # the runtime loader resolves. + - name: Assert the .so contract (EGL enabled, loader symbols present) + shell: bash + run: | + # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 + # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing + # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) + set -euo pipefail + SO="$(cat so_path)" + echo "checking $SO" + missing=0 + # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot + # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be + # silently dropped from the loop), and the count is asserted below so a refactor of the + # loader away from b"..." literals cannot quietly turn this whole check into a no-op that + # iterates zero times and passes. + # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would + # abort the script before the explicit ::error guard below can say WHY it failed; the + # guard on nsyms is the intended reporter for that case. + syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ + | sed 's/^b"//; s/"$//' | sort -u || true) + nsyms=$(echo "$syms" | grep -c . || true) + if [ "$nsyms" -lt 13 ]; then + echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" + missing=1 + fi + # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- + # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a + # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the + # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's + # status, so a library that HAS the symbol is reported as missing it. Measured on a real + # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both + # missing and failed the step. Note the obvious repair does NOT work -- materializing the + # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the + # producer, and it fails identically (measured). Today's release-sized .so happens to fit in + # the buffer, which is the only reason this has not fired yet. + exported="$(nm -D --defined-only "$SO")" + strs="$(strings "$SO")" + for sym in $syms; do + # Line-anchored: wrap in newlines so the pattern can require a whole line, the same + # thing `grep " T $sym$"` was expressing. + if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then + echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" + missing=1 + fi + done + # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the + # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look + # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub + # build really lacks is the dlopen target name and the import call itself. + for s in "libEGL.so.1" "eglCreateImageKHR"; do + if [[ "$strs" != *"$s"* ]]; then + echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" + missing=1 + fi + done + test "$missing" -eq 0 + echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" + + # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. + generate-bridge: + uses: ./.github/workflows/bridge.yml + + drm-deb: + name: unattended-wayland deb (verification build) + needs: generate-bridge + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + # Same list the stock linux job needs, plus the flutter desktop toolchain and the three + # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). + sudo apt-get install -y \ + clang cmake curl gcc git g++ ninja-build meson pkg-config \ + libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Setup flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Patch flutter + shell: bash + run: | + cd $(dirname $(dirname $(which flutter))) + # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off + # the pinned value, because the failed test becomes the script's exit status. An explicit + # if/else skips instead. Reading the values from the environment rather than interpolating + # github expressions into the script also keeps this off zizmor's template-injection list. + # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by + # actionlint and breaks workflow linting.) + if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then + git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" + else + echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" + fi + + - name: Build the unattended-wayland deb + shell: bash + run: | + set -euo pipefail + # The features have to be on the cargo line HERE, because the packaging line below passes + # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for + # the list rather than repeating it -- get_features() is the single definition of what + # these flags mean, and a hardcoded copy silently ships something other than what + # `build.py --drm` produces the moment that function changes. The flags must be the same + # on both lines for that to hold, so keep them in one variable. + DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) + FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" + echo "features from build.py: $FEATURES" + # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo + # line that builds a stock binary, which only the staged-binary marker check would catch. + # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend + # on the order get_features happens to append them (failing a correct build the day they + # are reordered) and would also match a future feature that merely contains "drm", the same + # trap build.py avoids by splitting on commas rather than testing a substring. + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --release --features "$FEATURES" + python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo + + # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, + # and assert the two things that make it the drm variant at all. + - name: Assert the deb is a real drm build + shell: bash + run: | + # Strict mode so the mid-script checks can fail the step (without it only the LAST + # command's status counts and the greps above it are decorative). + set -euo pipefail + # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches + # before its own `test -n` could report, and on several matches produced a multi-line + # value whose `mv` failed with something unrelated to the real problem. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" + # Pipe-free for the same reason as the .so contract step above (see the comment there: + # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under + # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the + # shape is the hazard and the next `-q` added here would inherit it silently. + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" + exit 1 + fi + # The library alone does not make this a drm build: build.py stages it whenever --drm is + # passed, independently of what was compiled, and the deb name is what tells a user this + # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that + # only exists when the feature is compiled in -- otherwise a stock binary could ship + # under the unattended-wayland name with a library it can never reach. + rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" + exit 1 + fi + mv "$deb" "${deb%.deb}-x86_64.deb" + + # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the + # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least + # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this + # file is not visible to whoever downloads it from the Actions UI. + - name: Measure the deb glibc floor + id: floor + shell: bash + run: | + # Strict mode for the same reason as the assert step above. The floor extraction gets an + # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a + # bare pipeline failure. + set -euo pipefail + # Same nullglob array + count assertion as the assert step above, for the same two + # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and + # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This + # was the sibling left behind when that one was fixed.) + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor + floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ + | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" + test -n "$floor" + echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" + echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" + + # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays + # out of the published release either way; the name states the floor so nobody installs it on an + # older distro and hits a bare loader error. + - name: Upload the deb + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb + path: rustdesk-unattended-wayland-*-x86_64.deb 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/Cargo.toml b/Cargo.toml index a7b2aca7743..2fac88c0057 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,13 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] +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"] plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ diff --git a/build.py b/build.py index 9579618574f..9ebcf0eba52 100755 --- a/build.py +++ b/build.py @@ -1,12 +1,16 @@ #!/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 @@ -130,6 +134,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 +289,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 +317,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 (see + # docs/DRM_CAPTURE_SECURITY.md), 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') @@ -316,6 +375,271 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunction= 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 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' + 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:'): + out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\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: system2(f'cargo build --locked --features {features} --lib --release') @@ -352,9 +676,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;') @@ -362,10 +699,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') system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/share/rustdesk') @@ -389,9 +784,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;') @@ -399,6 +838,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("..") @@ -473,6 +914,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'): @@ -488,7 +942,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/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md new file mode 100644 index 00000000000..9f0c9860027 --- /dev/null +++ b/docs/DRM_CAPTURE_SECURITY.md @@ -0,0 +1,255 @@ +# DRM/KMS capture — security model & threat model + +The optional `drm` feature adds a Linux capture backend that reads the active +scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent +dialog**. It exists for unattended / login-screen / Wayland scenarios where the +portal prompt is not acceptable. Because it bypasses consent, treat it as a +**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. + +## How it works + +Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' +framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so +the `drm` feature does the read **in-process in that root service**: it +`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no +`setcap` helper. On the **default (split) path** the root service does not touch +pixels: it exports the active scanout as a DMA-BUF and passes just that +**read-only** fd to the unprivileged user `--server` over a dedicated +service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an +**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is +imported once and re-imports are elided), detiles/converts it to linear RGBA in +its own unprivileged address space, and feeds the encoder — so **on that path** +the root service never copies scanout pixels and never loads libEGL/libGLESv2 +(measured on the running service, see *Auditing*). Only the **CPU fallback path** +(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer +has no render node of its own, see *When the CPU fallback is chosen* below) +copies the scanout to packed BGRA inside the root service and streams those bytes +over `_drm`. + +**The no-GL property is a property of the default path, not of the process.** Be +precise about it, because the CPU fallback is the whole reason the split exists: +converting a scanout in-process means decoding whatever layout it is in, and a +tiled scanout (the common case on modern Intel and AMD) can only be decoded +through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process +step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the +scanout needs a GPU detile. So a host that has fallen back to the CPU path can +map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about +that is bound the cases: the fallback is entered only for the three reasons +listed below, never as a silent degradation of the split path (the loader refuses +a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot +turn into "convert in the privileged process"), and a linear or CPU-mappable +scanout is converted without touching GL. Every host measured here runs the split +path with zero GL regions in the service; a CPU-fallback host is a different +posture and is worth measuring separately. This mirrors the Windows +`portable_service` split (a privileged process captures, an unprivileged one +presents) but reuses RustDesk's own hardened IPC. + +- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the + library or one of its runtime deps is missing the load fails cleanly and the + caller falls back to the PipeWire/portal path. +- The loader also **refuses a library that cannot do the split** — and, more + broadly, any version outside the vetted window. Accepted is exactly the pinned + minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is + refused (`0.4.x` included, even though it carries the split entry points, because + it decodes a padded scanout pitch at the wrong stride), and a **newer minor is + refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only + field-by-field verified against the pinned minor; widening the window is a + deliberate act done together with re-verifying the layouts and moving the + build pin. Independently of the version report, a library that does not + actually export + `drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or + pre-release build) is refused as well. The only way to capture with such a library is the + in-process convert, which in the root service means loading the vendor GL stack + there, so it is refused and the caller falls back to PipeWire/portal. The + privileged process therefore never loads GL because of which file happened to + be on the load path; the CPU fallback below is entered only for a fact about + the seat or the consumer. +- The reader restricts the device it opens to a realpath under `/dev/dri/` + (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode + (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or + installed by this package**: there is no `setcap`, no capability-bearing file, + and no capture group in this deployment. Being precise about what that does + and does not guarantee: an empty `helper_path` is not by itself a "helper + disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six + hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the + directory this package installs into, and `fork`/`exec`s the first executable + it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is + unreachable for two independent reasons: the root service holds + `CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the + shared library, so no helper exists at any of those paths. They are all + root-writable-only, so a helper appearing there would not be an escalation + either, but the honest statement is "a privileged child is spawned only if a + helper binary exists at one of those fixed root-owned paths, and this package + never installs one", not "never". +- The `_drm` socket lives beside the hardened `_service` socket + (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` + can connect, but every accepted peer is authorized in `handle_drm_conn` + (`authorize_service_scoped_ipc_connection`: peer must be root or the active + session uid, with a `/proc//exe` identity match). Connectable is not + authorized. + +## Threat model + +- **Consent bypass.** This mode does not show the portal "select what to share" + prompt. On a misconfigured install it could expose the login screen, the lock + screen, or another local user's graphical session. +- **The scanout parse runs in the root service.** Moving the read in-process + removes the old `setcap` helper and its world-exec attack surface. On the + **default (split) path** the root service does only a **metadata-only** parse + of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer + detile / pixel-format conversion runs in the **unprivileged `--server`**, + outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only — + geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4` + (`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side, + because the format check is delegated to the unprivileged converter, which + handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30, + HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the + DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and + `drm_reader` `dup()`s it — which shares the same open file description and so + preserves that access mode — so the unprivileged consumer can map the scanout + for reading but never write into the live framebuffer. On the **CPU fallback + path** the pixel-format conversion / detile instead runs inside the + `CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has + format / stride / geometry and integer-overflow guards (`drm_reader.rs` + `grab`), and non-32bpp scanouts are rejected before the copy. The device is + realpath-gated to `/dev/dri/` on both paths. +- **`_drm` is a screen-content channel.** It is authorized per connection (see + above); without that authz any local process could read the screen. Authorization + is also **re-checked on every frame**, not only at accept, because DRM/KMS + capture is not session-scoped: it grabs the physical scanout of a CRTC no matter + which session owns the display. So when the active session changes -- a user + logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than + continued (`drm: _drm peer no longer matches the active session`; observed with + peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes + with it). That is what stops an outgoing greeter process from capturing the + logged-in user's screen. The cost is a reconnect, not the session: the client + re-establishes itself against the new session's `--server` on its own in about + 2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the + **default (split) path** the channel carries the scanout DMA-BUF fd, passed to + the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor + (the `--server` holds an import-once EGLImage cache, so a given scanout buffer + is imported once and re-imports are elided); the peer can map the scanout for + reading but cannot write it. The **CPU fallback path** instead carries plain + packed-BGRA bytes over the same authorized socket (no fd passing, no shared + memory). +- **When the CPU fallback is chosen.** The split path is the default; the + consumer asks the service for the CPU-converted frame in two cases: no render + node can be opened for this seat, or a previous convert on this display + already failed. A third case is a **multi-GPU safety fallback**: if + the service could not name the render node of the GPU that exports the scanout + (an older `libdrmtap` without `drmtap_render_node`) and the host has more than + one render node, the consumer refuses to guess one, because importing a scanout + on a device that did not export it can succeed and return corrupted pixels + rather than fail. The conversion then happens in the service, on the device it + already has open, so it is correct by construction. Hosts with a single render + node have nothing to pick wrong and keep the DMA-BUF fast path. +- **The display wake injects synthetic input from the root service.** It is + compiled in only with the `drm-wake` feature, which `build.py --drm` adds on + top of `drm`, and it can be switched off at runtime with + `enable-drm-display-wake=N`. Building with `--features drm` alone leaves no + wake code in the binary at all, so an operator auditing the deb can answer + "is the injection path even present here?" from the artifact. A + compositor that idles long enough DISABLES a connector, leaving no scanout for + any backend, so on a `_drm` handshake that finds a CONNECTED display with no + CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to + make the compositor re-enable it. The virtual device **declares** two relative + axes and `BTN_LEFT`, because libinput classifies a device before it will treat + its events as pointer activity at all and a single axis with no buttons is + ignored outright (measured three ways on the same idle machine). What it + actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no + button press, no key events. This is deliberate input injection by privileged + code, so its bounds are worth stating precisely: + - it can only be reached through an **already-authorized** `_drm` connection + (same per-connection authz as every other use of the channel), so it grants + nothing to a local attacker that the channel itself does not; + - it runs in the root service because that is the only place it can: + `/dev/uinput` is root-only here, and a modeset of our own is not an option + since the compositor holds DRM master (the sysfs `dpms` attribute is + read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by + uid, refuse root, and are desktop-specific; + - the trigger is narrow — a connected-but-undriven connector, not "no + frames" — and connectors a wake demonstrably cannot bring back are + remembered by connector identity and stop triggering. That memory is + per-connector rather than global, so a permanently dark connector cannot + suppress the wake for a different panel, and it drops any entry later seen + scanning out. Note what that recovery rule does and does not give you: it + clears the moment the display is driven **by anything**, but nothing else + retries, so a connector latched after a wake that failed for a transient + reason stays latched until that display comes back some other way — on an + unattended host, typically not until the service restarts. It is a + deliberate trade against waking on every connection forever for a display + that is never coming; + - it is rate limited to **one wake per 20 s process-wide** with exactly one + concurrent winner (compare-exchange claim), so a reconnect storm cannot + become an input-injection storm. That bounds the injection RATE. It does + not bound how long a screen stays lit, and neither does the one-shot + property below: 20 s is shorter than every idle period measured below, so a + remote peer that reconnects in a loop can have the panel relit after each + idle-off. What that peer gains is a lit panel on a machine whose screen it + is already authorized to watch: it is visible to someone standing there, + not additional access; + - the wake is **one-shot: it resets the compositor's idle timer, it does not + hold the display on**. If nothing else keeps the session awake, the connector + idles off again one full idle period later -- measured 2026-07-31: 30.3 s at + a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a + screen lit for the length of a session is the job of RustDesk's existing + keep-awake inhibitor, not of this wake, which only recovers a connector that + is *already* dark; + - the uinput device is created and destroyed around the emit — nothing + persists in the input stack between wakes; + - without `/dev/uinput` the wake is skipped and latched off. Such a session + was already view-only (input injection on Wayland needs uinput too), so + this adds no new failure mode. + +## Deployment + +- **Off by default.** The `drm` feature is **not** in the default feature set and + is **not** enabled in standard release packages; the drm-off build is + byte-identical to upstream. Build it explicitly with + `python3 build.py --flutter --drm` (Linux only). +- **Separate opt-in package.** A `--drm` build ships as a distinctly named + `rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` -- + `Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the + consent-free variant, so it belongs in an audit of this metadata), so + enabling consent-free capture is an explicit install choice. +- **Bundled library, no capabilities.** The package installs the versioned + `libdrmtap.so.0..` plus a `libdrmtap.so.0` soname symlink under + `/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path + (`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not** + register the directory with the dynamic linker: no + `/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a + private library cannot shadow a system one for unrelated binaries + (Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a + development build reached through `LD_LIBRARY_PATH`. + + There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: + the capture runs inside the root `--service`, which already holds the + capability it needs. Hosts without `/dev/dri` access (or where the library + fails to load) transparently fall back to the PipeWire/portal path. +- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which + landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it + straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack + (2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as + "any 18.04". That is an API statement, not a binary-compatibility one: + the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the + shipped binaries carry that build host's glibc floor. Running on an older distribution means + building the deb there (or in a matching container), which the libdrm floor above permits. + Capture also requires an active KMS scanout (a Wayland/KMS session with a display + on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA + X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. +- **Recommended for** single-user, physically-controlled, or unattended hosts. + +## Auditing + +```bash +# the bundled capture library and its soname symlink — no capabilities are set on either +ls -l /usr/lib/rustdesk/libdrmtap.so.0* +# the dlopen names the symlink by absolute path, so what matters is where the symlink points: +readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package +# and there should be no other object left beside it (a leftover is not loaded on its own, but it +# is what a stray ldconfig over this directory would repoint the symlink to): +ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object +ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped) +# confirm no privileged helper is present (there should be none) +getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output +``` diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 0af7dfe0f66..da056b46d62 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.2). 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"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] 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..f12df71f79b --- /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`). See docs/DRM_CAPTURE_SECURITY.md. + +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..63b46ce8b7b --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,410 @@ +// 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 (lib, name) = candidates + .iter() + .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + // 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/src/ipc.rs b/src/ipc.rs index 188c2e4677e..b3abeeb55a8 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,6 +3,21 @@ 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")))] @@ -60,6 +75,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, }; @@ -481,6 +499,51 @@ pub enum Data { 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")] diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 0dd43855eec..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 { diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs new file mode 100644 index 00000000000..c2c399e6fe5 --- /dev/null +++ b/src/ipc/drm.rs @@ -0,0 +1,1799 @@ +// 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). `scrap::is_x11()` is the UNMEMOISED path. + 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 scrap::is_x11() { + 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/platform/linux.rs b/src/platform/linux.rs index 06cee3092bf..68a005ff75b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -361,6 +361,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 +403,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() { @@ -680,6 +730,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()); @@ -810,6 +894,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()); @@ -848,7 +955,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" @@ -927,6 +1065,15 @@ pub fn get_active_userid_fresh() -> String { 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() { @@ -1939,6 +2086,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; } diff --git a/src/server.rs b/src/server.rs index f02a15a7faa..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")] @@ -599,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() { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 8531076a9fe..3647d7ee6e2 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -65,6 +65,13 @@ 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); @@ -328,6 +335,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(); } } @@ -434,6 +451,33 @@ 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) { diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs new file mode 100644 index 00000000000..d447715df2d --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,1670 @@ +// 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(super) fn is_available_cached() -> bool { + matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) +} + +/// MAY BLOCK for seconds: never a routing gate. +pub(super) fn is_available() -> bool { + let verdict = { + let mut st = DRM_STATE.lock().unwrap(); + if let ProbeState::Unavailable(since) = &*st { + if since.elapsed() >= NEGATIVE_TTL { + publish_probe_state(&mut st, ProbeState::Unknown); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + } + } + match &*st { + ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), + ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unknown => None, // fall through and probe with the lock released + } + }; + if let Some((available, stale)) = verdict { + if stale { + refresh_available_async(); + } + return available; + } + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + } + let _in_flight = ProbeInFlightGuard; + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let available = 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)); + true + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + false + } + 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())); + } else { + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + } + false + } + }; + drop(st); + available +} + +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. `scrap::is_x11()` is the UNMEMOISED form. + for _ in 0..10 { + if scrap::is_x11() { + 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)) +} + +/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let multi = list.len() > 1; + let mut infos = augment_with_wayland_geometry(&list); + // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host + // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so + // the index space stays aligned with get_capturer_info(). A single-display host stays online. + if multi { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (idx, info) in infos.iter_mut().enumerate() { + let key = match list.get(idx) { + Some(d) => connector_key(d), + None => continue, + }; + if health.get(&key).is_some_and(|h| h.demoted()) { + info.online = false; + } + } + } + Some(infos) +} + +/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes +/// the advertised primary and geometry agree, but not below two connectors or two outputs, where +/// `augment_with_wayland_geometry` declines to run the assignment. +pub(super) fn get_primary_index() -> usize { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return 0, + }; + let wl = scrap::wayland::display::get_displays(); + if wl.displays.is_empty() { + return 0; + } + assign_wayland_outputs(&list, &wl.displays) + .iter() + .position(|assigned| *assigned == Some(wl.primary)) + .unwrap_or(0) +} + +/// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); + if drm.len() < 2 || wl.displays.len() < 2 { + return infos; + } + let matched = assign_wayland_outputs(drm, &wl.displays); + for (i, info) in infos.iter_mut().enumerate() { + let Some(w) = matched[i].map(|j| &wl.displays[j]) else { + continue; + }; + info.x = w.x; + info.y = w.y; + 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 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 1d4deeb65db..aa6893f3942 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; } diff --git a/src/server/wayland.rs b/src/server/wayland.rs index dacce9485ae..ffdf12c9821 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,81 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// 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; + } + scrap::wayland::display::clear_wayland_displays_cache(); + let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { + log::warn!("Failed to get desktop rect for uinput"); + 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( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + }; + // 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 +189,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 { @@ -242,6 +319,24 @@ pub(super) async fn check_init() -> ResultType<()> { } 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; + if let Some(displays) = super::drm_capturer::get_display_infos() { + // DRM connector order is not the compositor's primary; resolve the real primary from + // the compositor layout (matched by normalized connector name), not a hardcoded index 0. + return Ok((displays, super::drm_capturer::get_primary_index())); + } + } check_init().await?; // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); @@ -260,6 +355,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 _; @@ -274,18 +382,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, From 9a81c8a1383dde703e8b667eef4fc924d52e514e Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:31:09 +0800 Subject: [PATCH 101/121] Drm deb in release workflow (#15776) * docs(agents): add a comment-length rule Comments were growing to document rejected alternatives, past bugs and measurements. That belongs in the commit message, not the source. Co-Authored-By: Claude Opus 5 * ci(drm): build the unattended-wayland deb in the release workflow The deb was built by a separate drm-capture workflow on a plain runner, so it diverged from every other Linux deb: different base, different vcpkg/ffmpeg, different toolchain. Move it into flutter-build.yml as build-rustdesk-linux-drm, mirroring build-rustdesk-linux's x86_64 path -- same ubuntu18.04 container, same vcpkg install, same rust and flutter. libdrmtap is built on the runner first and handed to the container via DRMTAP_PREBUILT_DIR, because bionic's meson is too old to build it. The job is ungated, so the --drm packaging path is exercised on every PR; only publishing stays gated on upload-artifact. drm-capture.yml is deleted along with docs/DRM_CAPTURE_SECURITY.md -- the 29 drm unit tests that workflow ran are no longer executed by CI. Three bugs the move exposed: - build.py anchored the libdrmtap paths on abspath(__file__), which is only cwd-independent on Python >= 3.9 (bpo-20443). The packaging container runs 3.6 and chdir's into flutter/, so the ABI-gate cross-check resolved one directory off and every --drm packaging run would have died with FileNotFoundError. Captured as REPO_ROOT at import instead. - DRMTAP_PREBUILT_DIR no longer needs DRMTAP_ALLOW_UNPINNED. A prebuilt dir inside the repo's own third_party/libdrmtap at the pinned sha is the pinned object, not an override, and is now verified as such. - The variant's Depends carried a bare libdrm2. libdrmtap needs drmModeGetFB2, so it is libdrm2 (>= 2.4.95); below that the package installed and could never capture. The loader also logs the dlerror now instead of discarding it, so a soname or glibc mismatch is named rather than surfacing as a generic "libdrmtap not available". Co-Authored-By: Claude Opus 5 * fix(drm): declare the unattended-wayland deb's real libc6 and libdrm floors libdrmtap is built on the ubuntu-22.04 runner while the rest of the deb comes from the ubuntu18.04 container, so the package has a mixed glibc floor and declared neither half. It installed happily on Ubuntu 20.04 / Debian 11 (glibc 2.31), then dlopen failed on GLIBC_2.34 and capture degraded to the PipeWire portal -- the one thing this variant exists to avoid. Measure the floor off the staged objects and put it in Depends, so apt refuses with a reason instead of handing over a package that can never capture. Measured rather than written down: the number moves whenever either base does, and it lands exactly on RHEL/Rocky 9 (glibc 2.34), where one off-by-one decides whether that whole family can install. drmModeGetFB2 landed in libdrm 2.4.101, not 2.4.95 -- checked against the libdrm tags, xf86drmMode.h first declares it in 2.4.101. The old floor admitted Debian 10 (2.4.97), where the .so is linked -z now and dies on an undefined symbol at dlopen. libdrmtap's own meson.build carries the same wrong number. Upload the deb on always(): the run that fails the drm check is the one whose artifact is most worth downloading. Publish stays gated on success, so an unverified build still cannot reach a release. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 --- .github/workflows/drm-capture.yml | 449 ---------------------------- .github/workflows/flutter-build.yml | 270 +++++++++++++++++ AGENTS.md | 8 + build.py | 86 +++++- docs/DRM_CAPTURE_SECURITY.md | 255 ---------------- libs/scrap/src/common/drm_render.rs | 2 +- libs/scrap/src/common/drmtap_dl.rs | 17 +- 7 files changed, 364 insertions(+), 723 deletions(-) delete mode 100644 .github/workflows/drm-capture.yml delete mode 100644 docs/DRM_CAPTURE_SECURITY.md diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml deleted file mode 100644 index 2efc6eabd97..00000000000 --- a/.github/workflows/drm-capture.yml +++ /dev/null @@ -1,449 +0,0 @@ -name: DRM capture (opt-in drm feature) - -# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact -# up/download used by the deb job authenticates with the runtime token, not this one. Declared at -# the workflow level so the reusable bridge workflow called below inherits the same bound. -permissions: - contents: read - -# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to -# record that a given commit on master was verified. -concurrency: - group: drm-capture-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows -# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related -# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. -# -# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is -# the drm-off configuration and that stays the primary verified one. - -on: - workflow_dispatch: - pull_request: - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - push: - branches: - - master - # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push - # that touches only the missing paths (a squash merge, a direct push) skips re-verification. - paths: - - "libs/scrap/src/common/drm_reader.rs" - - "libs/scrap/src/common/drm_render.rs" - - "libs/scrap/src/common/drmtap_dl.rs" - - "libs/scrap/src/common/mod.rs" - - "libs/scrap/Cargo.toml" - # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes - # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: - # measured over the last 100 commits, it alone would have fired this workflow 13 times and - # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release - # build, almost always for a dependency the drm path never touches. A lockfile bump that - # does affect it arrives with a manifest or source change, which is triggered above. - - "Cargo.toml" - - "src/ipc.rs" - - "src/ipc/**" - - "src/server/drm_capturer.rs" - - "src/server/wayland.rs" - - "src/server/display_service.rs" - # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the - # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the - # whole drm verification. - - "src/server.rs" - - "src/server/input_service.rs" - - "src/platform/linux.rs" - - "build.py" - - ".github/workflows/drm-capture.yml" - -env: - VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - FLUTTER_VERSION: "3.24.5" - -jobs: - drm-tests: - name: drm unit tests (linux) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y \ - clang cmake curl gcc git g++ \ - libpam0g-dev libasound2-dev libunwind-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name - # filter would skip the sibling asserts that also matter in this configuration, notably the one - # bounding `size_of::()`, which the new DmabufDesc variant grows. - # The two skips are the same ones the stock CI applies: both need a real display server and fail - # on a headless runner regardless of this feature. - - name: Run rustdesk crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ - --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state - - # The capture backend itself lives in the scrap crate, so its unit tests are a separate - # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. - - name: Run scrap crate tests with the drm feature - shell: bash - run: | - cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib - - libdrmtap: - name: libdrmtap pin, build and .so contract - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Install libdrmtap build deps - shell: bash - run: | - sudo apt-get -y update - sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ - libegl1-mesa-dev libgles2-mesa-dev - - # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or - # moved pin fails here rather than in a release job. - - name: Fetch the pinned libdrmtap and build the .so - shell: bash - run: | - python3 - <<'PY' - import importlib.util, sys - spec = importlib.util.spec_from_file_location("b", "build.py") - b = importlib.util.module_from_spec(spec) - sys.argv = ["build.py"] - spec.loader.exec_module(b) - so = b.build_libdrmtap_so() - print(f"::notice::built {so}") - open("so_path", "w").write(so) - PY - - # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or - # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, - # so assert here that the object we would ship really carries EGL and really exports every symbol - # the runtime loader resolves. - - name: Assert the .so contract (EGL enabled, loader symbols present) - shell: bash - run: | - # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 - # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing - # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) - set -euo pipefail - SO="$(cat so_path)" - echo "checking $SO" - missing=0 - # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot - # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be - # silently dropped from the loop), and the count is asserted below so a refactor of the - # loader away from b"..." literals cannot quietly turn this whole check into a no-op that - # iterates zero times and passes. - # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would - # abort the script before the explicit ::error guard below can say WHY it failed; the - # guard on nsyms is the intended reporter for that case. - syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ - | sed 's/^b"//; s/"$//' | sort -u || true) - nsyms=$(echo "$syms" | grep -c . || true) - if [ "$nsyms" -lt 13 ]; then - echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" - missing=1 - fi - # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- - # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a - # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the - # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's - # status, so a library that HAS the symbol is reported as missing it. Measured on a real - # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both - # missing and failed the step. Note the obvious repair does NOT work -- materializing the - # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the - # producer, and it fails identically (measured). Today's release-sized .so happens to fit in - # the buffer, which is the only reason this has not fired yet. - exported="$(nm -D --defined-only "$SO")" - strs="$(strings "$SO")" - for sym in $syms; do - # Line-anchored: wrap in newlines so the pattern can require a whole line, the same - # thing `grep " T $sym$"` was expressing. - if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then - echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" - missing=1 - fi - done - # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the - # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look - # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub - # build really lacks is the dlopen target name and the import call itself. - for s in "libEGL.so.1" "eglCreateImageKHR"; do - if [[ "$strs" != *"$s"* ]]; then - echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" - missing=1 - fi - done - test "$missing" -eq 0 - echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" - - # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. - generate-bridge: - uses: ./.github/workflows/bridge.yml - - drm-deb: - name: unattended-wayland deb (verification build) - needs: generate-bridge - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: false - - - name: Checkout source code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - persist-credentials: false - - - name: Restore bridge files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bridge-artifact - path: ./ - - - name: Install prerequisites - shell: bash - run: | - sudo apt-get -y update - # Same list the stock linux job needs, plus the flutter desktop toolchain and the three - # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). - sudo apt-get install -y \ - clang cmake curl gcc git g++ ninja-build meson pkg-config \ - libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ - libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ - libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ - libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxdo-dev libxfixes-dev nasm wget \ - libdrm-dev libegl1-mesa-dev libgles2-mesa-dev - - - name: Setup vcpkg with Github Actions binary cache - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 - with: - vcpkgDirectory: /opt/artifacts/vcpkg - vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} - - - name: Install vcpkg dependencies - shell: bash - run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 - with: - toolchain: stable - targets: x86_64-unknown-linux-gnu - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - - name: Setup flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 - with: - channel: "stable" - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Patch flutter - shell: bash - run: | - cd $(dirname $(dirname $(which flutter))) - # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off - # the pinned value, because the failed test becomes the script's exit status. An explicit - # if/else skips instead. Reading the values from the environment rather than interpolating - # github expressions into the script also keeps this off zizmor's template-injection list. - # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by - # actionlint and breaks workflow linting.) - if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then - git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" - else - echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" - fi - - - name: Build the unattended-wayland deb - shell: bash - run: | - set -euo pipefail - # The features have to be on the cargo line HERE, because the packaging line below passes - # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for - # the list rather than repeating it -- get_features() is the single definition of what - # these flags mean, and a hardcoded copy silently ships something other than what - # `build.py --drm` produces the moment that function changes. The flags must be the same - # on both lines for that to hold, so keep them in one variable. - DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) - FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" - echo "features from build.py: $FEATURES" - # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo - # line that builds a stock binary, which only the staged-binary marker check would catch. - # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend - # on the order get_features happens to append them (failing a correct build the day they - # are reordered) and would also match a future feature that merely contains "drm", the same - # trap build.py avoids by splitting on commas rather than testing a substring. - for want in drm drm-wake; do - case ",$FEATURES," in - *",$want,"*) ;; - *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; - esac - done - cargo build --locked --lib --release --features "$FEATURES" - python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo - - # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, - # and assert the two things that make it the drm variant at all. - - name: Assert the deb is a real drm build - shell: bash - run: | - # Strict mode so the mid-script checks can fail the step (without it only the LAST - # command's status counts and the greps above it are decorative). - set -euo pipefail - # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches - # before its own `test -n` could report, and on several matches produced a multi-line - # value whose `mv` failed with something unrelated to the real problem. - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" - # Pipe-free for the same reason as the .so contract step above (see the comment there: - # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under - # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the - # shape is the hazard and the next `-q` added here would inherit it silently. - contents="$(dpkg -c "$deb")" - if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then - echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" - exit 1 - fi - if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then - echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" - exit 1 - fi - # The library alone does not make this a drm build: build.py stages it whenever --drm is - # passed, independently of what was compiled, and the deb name is what tells a user this - # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that - # only exists when the feature is compiled in -- otherwise a stock binary could ship - # under the unattended-wayland name with a library it can never reach. - rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert - if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then - echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" - exit 1 - fi - mv "$deb" "${deb%.deb}-x86_64.deb" - - # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the - # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least - # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this - # file is not visible to whoever downloads it from the Actions UI. - - name: Measure the deb glibc floor - id: floor - shell: bash - run: | - # Strict mode for the same reason as the assert step above. The floor extraction gets an - # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a - # bare pipeline failure. - set -euo pipefail - # Same nullglob array + count assertion as the assert step above, for the same two - # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and - # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This - # was the sibling left behind when that one was fixed.) - shopt -s nullglob - debs=(rustdesk-unattended-wayland-*-x86_64.deb) - if [ "${#debs[@]}" -ne 1 ]; then - echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" - exit 1 - fi - deb="${debs[0]}" - rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor - floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ - | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" - test -n "$floor" - echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" - echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" - - # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays - # out of the published release either way; the name states the floor so nobody installs it on an - # older distro and hits a bare loader error. - - name: Upload the deb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb - path: rustdesk-unattended-wayland-*-x86_64.deb diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 63526f95e3b..95cfdd8e3a7 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -1749,6 +1749,276 @@ jobs: files: | res/rustdesk-${{ env.VERSION }}*.zst + # Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same + # rust and flutter -- only with the drm feature on, so it ships as the separate + # rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too + # old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and + # flatpak need that job, and a failure here must not skip them. + build-rustdesk-linux-drm: + needs: [generate-bridge] + name: build rustdesk linux drm x86_64 + runs-on: ubuntu-22.04 + steps: + - name: Export GitHub Actions cache environment variables + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Maximize build space + run: | + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/lib/android + sudo rm -rf /usr/share/dotnet + sudo apt-get update -y + sudo apt-get install -y nasm + sudo apt-get install -y qemu-user-static + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Set Swap Space + uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0 + with: + swap-size-gb: 12 + + - name: Free Space + run: | + df -h + free -m + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ env.RUST_VERSION }} + targets: x86_64-unknown-linux-gnu + components: "rustfmt" + + - name: Save Rust toolchain version + run: | + RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}') + echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV + + - name: Disable rust bridge build + run: | + # only build cdylib + sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + doNotCache: false + + - name: Install vcpkg dependencies + run: | + sudo apt install -y libva-dev && apt show libva-dev + if ! $VCPKG_ROOT/vcpkg \ + install \ + --triplet x64-linux \ + --x-install-root="$VCPKG_ROOT/installed"; then + find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do + echo "$_1:" + echo "======" + cat "$_1" + echo "======" + echo "" + done + exit 1 + fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true + shell: bash + + # The container's meson is too old to build libdrmtap, so build it here from the pin in + # build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR. + - name: Build libdrmtap + run: | + sudo apt-get install -y meson ninja-build pkg-config \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + print(f"::notice::built {b.build_libdrmtap_so()}") + PY + shell: bash + + - uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26 + name: Build rustdesk + id: vcpkg + with: + arch: x86_64 + distro: ubuntu18.04 + githubToken: ${{ github.token }} + setup: | + ls -l "${PWD}" + ls -l /opt/artifacts/vcpkg/installed + dockerRunArgs: | + --volume "${PWD}:/workspace" + --volume "/opt/artifacts:/opt/artifacts" + shell: /bin/bash + install: | + apt-get update -y + echo -e "installing deps" + apt-get install -y \ + build-essential \ + clang \ + cmake \ + curl \ + gcc \ + git \ + g++ \ + libayatana-appindicator3-dev \ + libasound2-dev \ + libclang-10-dev \ + libgstreamer1.0-dev \ + libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev \ + libpam0g-dev \ + libpulse-dev \ + libva-dev \ + libxcb-randr0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxdo-dev \ + libxfixes-dev \ + llvm-10-dev \ + nasm \ + ninja-build \ + pkg-config \ + tree \ + python3 \ + rpm \ + unzip \ + wget \ + xz-utils \ + libssl-dev + # we have libopus compiled by us. + apt-get remove -y libopus-dev || true + # output devs + ls -l ./ + tree -L 3 /opt/artifacts/vcpkg/installed + run: | + # disable git safe.directory + git config --global --add safe.directory "*" + # rust + pushd /opt + # do not use rustup, because memory overflow in qemu + wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz + tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz + cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh + rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu + # edit config + mkdir -p ~/.cargo/ + echo """ + [source.crates-io] + registry = 'https://github.com/rust-lang/crates.io-index' + """ > ~/.cargo/config + cat ~/.cargo/config + # start build + pushd /workspace + export VCPKG_ROOT=/opt/artifacts/vcpkg + # use the .so built on the runner; build.py checks it is the pinned checkout + export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg + # ask build.py for the features so this line and the packaging line cannot drift + FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features) + # an empty or error-shaped value would silently build a stock binary + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --features "$FEATURES" --release + rm -rf target/release/deps target/release/build + rm -rf ~/.cargo + + # Setup Flutter + # disable git safe.directory + git config --global --add safe.directory "*" + export PATH=/opt/flutter/bin:$PATH + pushd /opt + wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz + flutter doctor -v + + if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then + pushd /opt/flutter + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + popd + fi + + # build flutter + pushd /workspace + export CARGO_INCREMENTAL=0 + export DEB_ARCH=amd64 + python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo + for name in rustdesk*??.deb; do + mv "$name" "${name%%.deb}-x86_64.deb" + done + + # build.py can exit 0 on some inner failures, so check the artifact rather than the status. + # The package name is the informed consent for consent-free capture, so a stock binary must + # never ship under it: assert the bundled library AND the dlopen path in the binary. + - name: Check the deb is a drm build + run: | + set -euo pipefail + # Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a + # hardcoded name fails with a bare exit 1 the first time those two drift. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "DRM_DEB=$deb" >> "$GITHUB_ENV" + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::$deb has no versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::$deb has no libdrmtap.so.0 soname symlink" + exit 1 + fi + rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::$deb was not built with the drm feature" + exit 1 + fi + shell: bash + + - name: Publish debian package + if: env.UPLOAD_ARTIFACT == 'true' + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + prerelease: true + tag_name: ${{ env.TAG_NAME }} + files: | + ${{ env.DRM_DEB }} + + # No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built. + # always(), because a deb that failed the check above is the one most worth downloading. + - name: Upload deb + if: always() && env.DRM_DEB != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ env.DRM_DEB }} + path: ${{ env.DRM_DEB }} + build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} runs-on: ${{ matrix.job.on }} diff --git a/AGENTS.md b/AGENTS.md index 8f558c95901..4ff5b1e75fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ * 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. +* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR. +* 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. diff --git a/build.py b/build.py index 9ebcf0eba52..b32e95672f8 100755 --- a/build.py +++ b/build.py @@ -15,6 +15,11 @@ 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") @@ -327,8 +332,8 @@ def get_features(args): # 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 (see - # docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it. + # 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( @@ -399,20 +404,40 @@ def ffi_bindgen_function_refactor(): DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1' +def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir): + # A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object, + # not an override, so it must not need the opt-in. This is how CI hands the library from a step + # that has meson to a packaging container that does not. + src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap') + try: + inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src + except ValueError: + return False + if not inside or not os.path.isdir(os.path.join(src, '.git')): + return False + try: + head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip() + except (subprocess.SubprocessError, OSError): + return False + return head == LIBDRMTAP_SHA + + 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 + if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt): + prebuilt = None overridden = [ name for name, value, pinned in ( ('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED), ('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED), - # `or None` so an empty value reads as unset here exactly as it does in - # build_libdrmtap_so(), which tests it for truthiness. Otherwise `DRMTAP_PREBUILT_DIR=` - # would demand the opt-in for an override that is not going to happen. - ('DRMTAP_PREBUILT_DIR', os.environ.get('DRMTAP_PREBUILT_DIR') or None, None), + ('DRMTAP_PREBUILT_DIR', prebuilt, None), ) if value != pinned ] @@ -452,7 +477,6 @@ def build_libdrmtap_so(): # 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() - repo_root = os.path.dirname(os.path.abspath(__file__)) # 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') @@ -474,7 +498,7 @@ def build_libdrmtap_so(): # `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') + 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) @@ -526,7 +550,7 @@ def _assert_so_has_egl(so_path): # 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. Same two markers the drm-capture workflow asserts in CI. + # lacks. try: with open(so_path, 'rb') as f: blob = f.read() @@ -566,11 +590,8 @@ def assert_so_satisfies_the_runtime_abi_gate(so_path): 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()) - # Anchored on THIS file, not on the cwd: both callers of stage_libdrmtap_into_deb have already - # chdir'd into flutter/ by the time they get here, so a cwd-relative path raises FileNotFoundError - # and fails every --drm packaging run. (It did; CI caught it.) - gate_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs') + # 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() @@ -613,6 +634,37 @@ def stage_libdrmtap_into_deb(so_path): 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 @@ -620,6 +672,8 @@ def retarget_control_to_drm_variant(): # 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 = [] @@ -628,7 +682,9 @@ def retarget_control_to_drm_variant(): out.append(f'Package: {DRM_PACKAGE_NAME}\n') out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') elif line.startswith('Depends:'): - out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n') + # 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) diff --git a/docs/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md deleted file mode 100644 index 9f0c9860027..00000000000 --- a/docs/DRM_CAPTURE_SECURITY.md +++ /dev/null @@ -1,255 +0,0 @@ -# DRM/KMS capture — security model & threat model - -The optional `drm` feature adds a Linux capture backend that reads the active -scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent -dialog**. It exists for unattended / login-screen / Wayland scenarios where the -portal prompt is not acceptable. Because it bypasses consent, treat it as a -**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. - -## How it works - -Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' -framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so -the `drm` feature does the read **in-process in that root service**: it -`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no -`setcap` helper. On the **default (split) path** the root service does not touch -pixels: it exports the active scanout as a DMA-BUF and passes just that -**read-only** fd to the unprivileged user `--server` over a dedicated -service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an -**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is -imported once and re-imports are elided), detiles/converts it to linear RGBA in -its own unprivileged address space, and feeds the encoder — so **on that path** -the root service never copies scanout pixels and never loads libEGL/libGLESv2 -(measured on the running service, see *Auditing*). Only the **CPU fallback path** -(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer -has no render node of its own, see *When the CPU fallback is chosen* below) -copies the scanout to packed BGRA inside the root service and streams those bytes -over `_drm`. - -**The no-GL property is a property of the default path, not of the process.** Be -precise about it, because the CPU fallback is the whole reason the split exists: -converting a scanout in-process means decoding whatever layout it is in, and a -tiled scanout (the common case on modern Intel and AMD) can only be decoded -through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process -step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the -scanout needs a GPU detile. So a host that has fallen back to the CPU path can -map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about -that is bound the cases: the fallback is entered only for the three reasons -listed below, never as a silent degradation of the split path (the loader refuses -a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot -turn into "convert in the privileged process"), and a linear or CPU-mappable -scanout is converted without touching GL. Every host measured here runs the split -path with zero GL regions in the service; a CPU-fallback host is a different -posture and is worth measuring separately. This mirrors the Windows -`portable_service` split (a privileged process captures, an unprivileged one -presents) but reuses RustDesk's own hardened IPC. - -- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the - library or one of its runtime deps is missing the load fails cleanly and the - caller falls back to the PipeWire/portal path. -- The loader also **refuses a library that cannot do the split** — and, more - broadly, any version outside the vetted window. Accepted is exactly the pinned - minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is - refused (`0.4.x` included, even though it carries the split entry points, because - it decodes a padded scanout pitch at the wrong stride), and a **newer minor is - refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only - field-by-field verified against the pinned minor; widening the window is a - deliberate act done together with re-verifying the layouts and moving the - build pin. Independently of the version report, a library that does not - actually export - `drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or - pre-release build) is refused as well. The only way to capture with such a library is the - in-process convert, which in the root service means loading the vendor GL stack - there, so it is refused and the caller falls back to PipeWire/portal. The - privileged process therefore never loads GL because of which file happened to - be on the load path; the CPU fallback below is entered only for a fact about - the seat or the consumer. -- The reader restricts the device it opens to a realpath under `/dev/dri/` - (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode - (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or - installed by this package**: there is no `setcap`, no capability-bearing file, - and no capture group in this deployment. Being precise about what that does - and does not guarantee: an empty `helper_path` is not by itself a "helper - disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six - hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the - directory this package installs into, and `fork`/`exec`s the first executable - it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is - unreachable for two independent reasons: the root service holds - `CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the - shared library, so no helper exists at any of those paths. They are all - root-writable-only, so a helper appearing there would not be an escalation - either, but the honest statement is "a privileged child is spawned only if a - helper binary exists at one of those fixed root-owned paths, and this package - never installs one", not "never". -- The `_drm` socket lives beside the hardened `_service` socket - (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` - can connect, but every accepted peer is authorized in `handle_drm_conn` - (`authorize_service_scoped_ipc_connection`: peer must be root or the active - session uid, with a `/proc//exe` identity match). Connectable is not - authorized. - -## Threat model - -- **Consent bypass.** This mode does not show the portal "select what to share" - prompt. On a misconfigured install it could expose the login screen, the lock - screen, or another local user's graphical session. -- **The scanout parse runs in the root service.** Moving the read in-process - removes the old `setcap` helper and its world-exec attack surface. On the - **default (split) path** the root service does only a **metadata-only** parse - of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer - detile / pixel-format conversion runs in the **unprivileged `--server`**, - outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only — - geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4` - (`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side, - because the format check is delegated to the unprivileged converter, which - handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30, - HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the - DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and - `drm_reader` `dup()`s it — which shares the same open file description and so - preserves that access mode — so the unprivileged consumer can map the scanout - for reading but never write into the live framebuffer. On the **CPU fallback - path** the pixel-format conversion / detile instead runs inside the - `CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has - format / stride / geometry and integer-overflow guards (`drm_reader.rs` - `grab`), and non-32bpp scanouts are rejected before the copy. The device is - realpath-gated to `/dev/dri/` on both paths. -- **`_drm` is a screen-content channel.** It is authorized per connection (see - above); without that authz any local process could read the screen. Authorization - is also **re-checked on every frame**, not only at accept, because DRM/KMS - capture is not session-scoped: it grabs the physical scanout of a CRTC no matter - which session owns the display. So when the active session changes -- a user - logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than - continued (`drm: _drm peer no longer matches the active session`; observed with - peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes - with it). That is what stops an outgoing greeter process from capturing the - logged-in user's screen. The cost is a reconnect, not the session: the client - re-establishes itself against the new session's `--server` on its own in about - 2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the - **default (split) path** the channel carries the scanout DMA-BUF fd, passed to - the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor - (the `--server` holds an import-once EGLImage cache, so a given scanout buffer - is imported once and re-imports are elided); the peer can map the scanout for - reading but cannot write it. The **CPU fallback path** instead carries plain - packed-BGRA bytes over the same authorized socket (no fd passing, no shared - memory). -- **When the CPU fallback is chosen.** The split path is the default; the - consumer asks the service for the CPU-converted frame in two cases: no render - node can be opened for this seat, or a previous convert on this display - already failed. A third case is a **multi-GPU safety fallback**: if - the service could not name the render node of the GPU that exports the scanout - (an older `libdrmtap` without `drmtap_render_node`) and the host has more than - one render node, the consumer refuses to guess one, because importing a scanout - on a device that did not export it can succeed and return corrupted pixels - rather than fail. The conversion then happens in the service, on the device it - already has open, so it is correct by construction. Hosts with a single render - node have nothing to pick wrong and keep the DMA-BUF fast path. -- **The display wake injects synthetic input from the root service.** It is - compiled in only with the `drm-wake` feature, which `build.py --drm` adds on - top of `drm`, and it can be switched off at runtime with - `enable-drm-display-wake=N`. Building with `--features drm` alone leaves no - wake code in the binary at all, so an operator auditing the deb can answer - "is the injection path even present here?" from the artifact. A - compositor that idles long enough DISABLES a connector, leaving no scanout for - any backend, so on a `_drm` handshake that finds a CONNECTED display with no - CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to - make the compositor re-enable it. The virtual device **declares** two relative - axes and `BTN_LEFT`, because libinput classifies a device before it will treat - its events as pointer activity at all and a single axis with no buttons is - ignored outright (measured three ways on the same idle machine). What it - actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no - button press, no key events. This is deliberate input injection by privileged - code, so its bounds are worth stating precisely: - - it can only be reached through an **already-authorized** `_drm` connection - (same per-connection authz as every other use of the channel), so it grants - nothing to a local attacker that the channel itself does not; - - it runs in the root service because that is the only place it can: - `/dev/uinput` is root-only here, and a modeset of our own is not an option - since the compositor holds DRM master (the sysfs `dpms` attribute is - read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by - uid, refuse root, and are desktop-specific; - - the trigger is narrow — a connected-but-undriven connector, not "no - frames" — and connectors a wake demonstrably cannot bring back are - remembered by connector identity and stop triggering. That memory is - per-connector rather than global, so a permanently dark connector cannot - suppress the wake for a different panel, and it drops any entry later seen - scanning out. Note what that recovery rule does and does not give you: it - clears the moment the display is driven **by anything**, but nothing else - retries, so a connector latched after a wake that failed for a transient - reason stays latched until that display comes back some other way — on an - unattended host, typically not until the service restarts. It is a - deliberate trade against waking on every connection forever for a display - that is never coming; - - it is rate limited to **one wake per 20 s process-wide** with exactly one - concurrent winner (compare-exchange claim), so a reconnect storm cannot - become an input-injection storm. That bounds the injection RATE. It does - not bound how long a screen stays lit, and neither does the one-shot - property below: 20 s is shorter than every idle period measured below, so a - remote peer that reconnects in a loop can have the panel relit after each - idle-off. What that peer gains is a lit panel on a machine whose screen it - is already authorized to watch: it is visible to someone standing there, - not additional access; - - the wake is **one-shot: it resets the compositor's idle timer, it does not - hold the display on**. If nothing else keeps the session awake, the connector - idles off again one full idle period later -- measured 2026-07-31: 30.3 s at - a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a - screen lit for the length of a session is the job of RustDesk's existing - keep-awake inhibitor, not of this wake, which only recovers a connector that - is *already* dark; - - the uinput device is created and destroyed around the emit — nothing - persists in the input stack between wakes; - - without `/dev/uinput` the wake is skipped and latched off. Such a session - was already view-only (input injection on Wayland needs uinput too), so - this adds no new failure mode. - -## Deployment - -- **Off by default.** The `drm` feature is **not** in the default feature set and - is **not** enabled in standard release packages; the drm-off build is - byte-identical to upstream. Build it explicitly with - `python3 build.py --flutter --drm` (Linux only). -- **Separate opt-in package.** A `--drm` build ships as a distinctly named - `rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` -- - `Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the - consent-free variant, so it belongs in an audit of this metadata), so - enabling consent-free capture is an explicit install choice. -- **Bundled library, no capabilities.** The package installs the versioned - `libdrmtap.so.0..` plus a `libdrmtap.so.0` soname symlink under - `/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path - (`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not** - register the directory with the dynamic linker: no - `/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a - private library cannot shadow a system one for unrelated binaries - (Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a - development build reached through `LD_LIBRARY_PATH`. - - There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: - the capture runs inside the root `--service`, which already holds the - capability it needs. Hosts without `/dev/dri` access (or where the library - fails to load) transparently fall back to the PipeWire/portal path. -- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which - landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it - straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack - (2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as - "any 18.04". That is an API statement, not a binary-compatibility one: - the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the - shipped binaries carry that build host's glibc floor. Running on an older distribution means - building the deb there (or in a matching container), which the libdrm floor above permits. - Capture also requires an active KMS scanout (a Wayland/KMS session with a display - on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA - X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. -- **Recommended for** single-user, physically-controlled, or unattended hosts. - -## Auditing - -```bash -# the bundled capture library and its soname symlink — no capabilities are set on either -ls -l /usr/lib/rustdesk/libdrmtap.so.0* -# the dlopen names the symlink by absolute path, so what matters is where the symlink points: -readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package -# and there should be no other object left beside it (a leftover is not loaded on its own, but it -# is what a stray ldconfig over this directory would repoint the symlink to): -ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object -ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped) -# confirm no privileged helper is present (there should be none) -getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output -``` diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs index f12df71f79b..6df6ea61d2d 100644 --- a/libs/scrap/src/common/drm_render.rs +++ b/libs/scrap/src/common/drm_render.rs @@ -1,7 +1,7 @@ // 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`). See docs/DRM_CAPTURE_SECURITY.md. +// 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; diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs index 63b46ce8b7b..0312c75bbce 100644 --- a/libs/scrap/src/common/drmtap_dl.rs +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -196,9 +196,20 @@ impl DrmtapLib { std::iter::once(INSTALLED).chain(DEV_ONLY).collect() }; unsafe { - let (lib, name) = candidates - .iter() - .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + 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) From 429c8c67111408bbc04e96a8a38252210fb1b1f2 Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Thu, 6 Aug 2026 21:31:35 -0300 Subject: [PATCH 102/121] Translate sign-in message to Portuguese (#15770) Translate sign-in message to Portuguese --- src/lang/ptbr.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 8d44d6140c0..61bf5cf484c 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -774,6 +774,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("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(); } From 6fd96dda6ec73bd03663c1c33493bceceb0e707d Mon Sep 17 00:00:00 2001 From: Panos Date: Fri, 7 Aug 2026 09:39:44 +0300 Subject: [PATCH 103/121] Update Greek translations for various terms (#15782) --- src/lang/el.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lang/el.rs b/src/lang/el.rs index 5ba349a9c44..deca79aa6fd 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -333,7 +333,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", "Θέμα"), @@ -709,9 +709,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", "Εμφάνιση εικονικού ποντικιού"), From 4234b99029bf32c23098b4eaeec8efc135c8e80a Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:20:57 +0800 Subject: [PATCH 104/121] WebClient: 3.44 webcodecs offline (#15722) * feat(web): zero-readback WebCodecs video path Decoded VideoFrames from js/src/webcodecs.js are handed to Flutter via window.onVideoFrame and imported GPU-side with createImageFromTextureSource; any failure unregisters the hook so the JS side falls back to RGBA readback. Co-Authored-By: Claude Fable 5 * fix(web): load bundled terminal font when Google CDNs are unreachable In air-gapped deployments GoogleFonts.robotoMono() cannot download the terminal font; when index.html signals offline mode, load the copy bundled with the web app under the family name google_fonts registers. Part of the fix for rustdesk/rustdesk-server-pro#996. Co-Authored-By: Claude Fable 5 * ci: bump windows arm64 to Flutter 3.44.8, add web build patch script apply_flutter_3.44_web_patches.sh prepares a 3.44.x web build on top of the shared source patches: qr_code_scanner's web impl needs dart:ui_web for the removed platformViewRegistry, and flutter/web/fonts is refreshed to the font paths the 3.44 engine requests. The disabled build-rustdesk-web job runs it automatically once FLUTTER_VERSION moves to 3.44.x, and version-guarded 'Patch flutter' steps no longer fail when the guard does not match. Co-Authored-By: Claude Fable 5 * fix(web): prevent stale WebCodecs frames across sessions Signed-off-by: fufesou * fix(web): harden WebCodecs reconnect and Flutter 3.44 patches Signed-off-by: fufesou * fix(ci): harden Flutter 3.44 patch input validation Validate required files before checking patch state, parameterize the theme-range validator, and prevent missing inputs from satisfying NO_MATCHES checks. Signed-off-by: fufesou * Remove unused code Signed-off-by: fufesou * fix(web): retry font loading and dispose stale decoded images Signed-off-by: fufesou * remove unused code Signed-off-by: fufesou * fix(web): Bad state: RenderBox was not laid out Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Fable 5 Co-authored-by: fufesou --- .../apply_flutter_3.44_source_patches.sh | 115 ++++++++++++++- .../patches/apply_flutter_3.44_web_patches.sh | 51 +++++++ .github/workflows/bridge.yml | 2 +- .github/workflows/flutter-build.yml | 35 ++++- flutter/lib/main.dart | 3 +- flutter/lib/mobile/pages/terminal_page.dart | 6 + flutter/lib/models/model.dart | 37 ++++- flutter/lib/models/native_model.dart | 7 + flutter/lib/models/web_model.dart | 67 +++++++++ flutter/lib/models/web_video_frame_queue.dart | 133 ++++++++++++++++++ flutter/lib/web/dummy.dart | 2 + flutter/lib/web/terminal_font.dart | 33 +++++ 12 files changed, 472 insertions(+), 19 deletions(-) create mode 100755 .github/patches/apply_flutter_3.44_web_patches.sh create mode 100644 flutter/lib/models/web_video_frame_queue.dart create mode 100644 flutter/lib/web/terminal_font.dart 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/workflows/bridge.yml b/.github/workflows/bridge.yml index a7b74fa55a1..9d31399c8db 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -30,7 +30,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/flutter-build.yml b/.github/workflows/flutter-build.yml index 95cfdd8e3a7..4f1dbb1c957 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -31,7 +31,7 @@ env: # engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7 # support is restored after the upstream-wide Flutter bump. The arm64 job patches the few # 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44"). - FLUTTER_WINDOWS_ARM_VERSION: "3.44.0" + FLUTTER_WINDOWS_ARM_VERSION: "3.44.8" # for arm64 linux because official Dart SDK does not work FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "${{ inputs.upload-tag }}" @@ -224,7 +224,9 @@ jobs: run: | cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter))) cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Patch RustDesk sources for Flutter 3.44 (arm64) # arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly @@ -595,7 +597,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 @@ -774,7 +778,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Workaround for flutter issue shell: bash @@ -1033,7 +1039,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1 id: setup-ndk @@ -1305,7 +1313,9 @@ jobs: - name: Patch flutter run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi - name: Restore bridge files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -2413,7 +2423,18 @@ jobs: shell: bash run: | cd $(dirname $(dirname $(which flutter))) - [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + fi + + - name: Patch sources for Flutter 3.44 web + # No-op while the web stays on Flutter 3.24.5; makes this job work as-is + # once FLUTTER_VERSION moves to 3.44.x (qr_code_scanner + fonts, see script). + shell: bash + run: | + if [[ "${{ env.FLUTTER_VERSION }}" == 3.44.* ]]; then + bash .github/patches/apply_flutter_3.44_web_patches.sh + fi # https://rustdesk.com/docs/en/dev/build/web/ - name: Build web diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 9bd68ed60a6..7e0a8cb2b71 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -588,7 +588,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/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index a4a76f9af0f..800b0f8f475 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -10,6 +10,8 @@ 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'; @@ -67,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}'); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 175e3ff2da3..4a6088bd3e6 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -1952,6 +1952,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); @@ -1963,11 +1969,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(); @@ -1978,11 +1989,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; @@ -3853,6 +3872,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; } @@ -3940,7 +3968,7 @@ class FFI { this.id = id; } - void onEvent2UIRgba() async { + Future onEvent2UIRgba() async { if (ffiModel.waitForImageDialogShow.isTrue) { ffiModel.waitForImageDialogShow.value = false; ffiModel.waitForImageTimer?.cancel(); @@ -3996,6 +4024,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 e73cbc0cb53..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'; @@ -283,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/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/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/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'); + } +} From d057fe14b2fb24d7a4cd6d64fb9f1c6d72dbea3f Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 8 Aug 2026 13:33:25 +1200 Subject: [PATCH 105/121] docs: fix singular contribution in docs/CONTRIBUTING.md (#15789) Co-authored-by: pi --- docs/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 11190fa54e45fd244ad46b46052f92be6a01d3c5 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Sat, 8 Aug 2026 13:33:58 +1200 Subject: [PATCH 106/121] docs: fix comma splice gui tutorial in README.md (#15787) Co-authored-by: pi --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ae5c8d37caf..b30a34cb218 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. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version. +Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version. Please download Sciter dynamic library yourself. From 291507664278f0df273c31f58b9318ff44b28a18 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Sun, 9 Aug 2026 07:04:54 -0300 Subject: [PATCH 107/121] fix(linux): bound the xrandr call in the wayland primary-display lookup (#15802) `try_xrandr_primary` runs a bare `Command::new("xrandr").output()`. Its two siblings in the same file, `try_kscreen_primary` and the gdbus one, both go through `run_with_timeout(.., COMMAND_TIMEOUT)`, and the comment above that helper says why: these commands are known to hang. xrandr is the one left bare. It matters because of where it runs. `get_primary_monitor` is called from `get_displays` with the process-wide `DISPLAYS` guard held, and on a Wayland host the caller can be the service, which has no DISPLAY and no session bus. An X client that blocks there blocks every consumer of the display list behind the same lock. No behaviour change when xrandr answers: same command, same parsing, one second of patience. --- libs/scrap/src/wayland/display.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index bed90fd7673..e3c5ebede2d 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -76,7 +76,9 @@ fn run_with_timeout( // 2. The distro may not have xrandr installed by default. // 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. fn try_xrandr_primary() -> Option { - let output = Command::new("xrandr").output().ok()?; + // Bounded like its two siblings below: this runs inside the held `DISPLAYS` guard, and from a + // service with no DISPLAY and no session bus, where an X client can block indefinitely. + let output = run_with_timeout("xrandr", &[], COMMAND_TIMEOUT, "xrandr")?; if !output.status.success() { return None; } From 7c23fd307349c2d436dbcadc9cff9d4099187a42 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:40:50 +0800 Subject: [PATCH 108/121] =?UTF-8?q?Revert=20"fix(linux):=20bound=20the=20x?= =?UTF-8?q?randr=20call=20in=20the=20wayland=20primary-display=20look?= =?UTF-8?q?=E2=80=A6"=20(#15806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 291507664278f0df273c31f58b9318ff44b28a18. --- libs/scrap/src/wayland/display.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index e3c5ebede2d..bed90fd7673 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -76,9 +76,7 @@ fn run_with_timeout( // 2. The distro may not have xrandr installed by default. // 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. fn try_xrandr_primary() -> Option { - // Bounded like its two siblings below: this runs inside the held `DISPLAYS` guard, and from a - // service with no DISPLAY and no session bus, where an X client can block indefinitely. - let output = run_with_timeout("xrandr", &[], COMMAND_TIMEOUT, "xrandr")?; + let output = Command::new("xrandr").output().ok()?; if !output.status.success() { return None; } From 594e63805c2fa2e6b214c07dd7869dba52f4d627 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 10 Aug 2026 16:05:13 +0800 Subject: [PATCH 109/121] harden login request retry --- src/server/connection.rs | 144 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/server/connection.rs b/src/server/connection.rs index 25d9b6792c6..90729fb8d10 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -363,6 +363,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, @@ -560,6 +563,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, @@ -2621,6 +2625,90 @@ impl Connection { 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); @@ -2698,6 +2786,9 @@ impl Connection { } // 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 { @@ -7003,6 +7094,59 @@ mod test { #[allow(unused)] use super::*; + #[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. From d407db9faed8d7f45184fd8862b5dd728a2ae6fb Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:07:12 +0800 Subject: [PATCH 110/121] fix(client): allow switch-sides back-connection in incoming-only mode (#15780) * fix(client): allow switch-sides back-connection in incoming-only mode "Switch sides" makes the controlled client run `--connect --switch_uuid `, which Client::_start rejected outright in incoming-only custom clients, so the feature silently dropped the session and never switched. Exempt exactly that back-connection: a default-conn session carrying a switch uuid may proceed. The uuid is then verified against the local server process in handle_hash(); if it is missing there (forged or expired), an incoming-only client now aborts with an error instead of falling through to password login, so the outgoing-connection restriction cannot be bypassed with a crafted --switch_uuid. Fixes rustdesk/rustdesk#11200 (discussion) Co-Authored-By: Claude Fable 5 * fix(client): validate switch-back grants before connecting - check pending peer/UUID grants before bypassing incoming-only mode - close rejected switch-back connections and suppress retries - keep grant consumption in handle_hash and test non-consuming checks Signed-off-by: 21pages * fix(client): prevent switch-back UUID reuse - claim pending switch-back grants before connecting - retain claimed grants to reject duplicate requests - bind authorization to the peer ID and UUID - use a shared TTL for switch-back grants Signed-off-by: 21pages * fix(client): defer switch UUID consumption until authentication Signed-off-by: 21pages * fix(client): reject repeated hash login in incoming-only mode Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: Claude Fable 5 Co-authored-by: 21pages --- src/client.rs | 91 ++++++++++++++++++++++++++++++++++++++-- src/ipc.rs | 23 ++++++++-- src/server/connection.rs | 84 +++++++++++++++++++++++++++---------- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/src/client.rs b/src/client.rs index 6f234786876..5f5f34cd0c6 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 @@ -3455,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 consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { +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 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; }; @@ -3466,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 @@ -3477,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, } @@ -3512,7 +3560,13 @@ 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(); @@ -3522,6 +3576,19 @@ pub async fn handle_hash( } } } + // 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; + } } // last password let mut password = lc.read().unwrap().password.clone(); @@ -4031,9 +4098,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/ipc.rs b/src/ipc.rs index b3abeeb55a8..52e79955d38 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -312,6 +312,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 { @@ -387,7 +395,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, @@ -1050,14 +1058,21 @@ 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 ); } diff --git a/src/server/connection.rs b/src/server/connection.rs index 90729fb8d10..b461a3effb9 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -91,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")] @@ -3085,7 +3089,7 @@ impl Connection { 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 { @@ -3825,17 +3829,18 @@ 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; } @@ -6139,23 +6144,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")))] @@ -7094,6 +7116,26 @@ 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| { From 947cb3f17b673b55dfcbe95b318749f8b44a7f7a Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 10 Aug 2026 16:45:27 +0800 Subject: [PATCH 111/121] propagates the hash-handler continuation result through both connection loops, allowing incoming-only rejection to terminate the connection while preserving existing login flows. --- src/client.rs | 11 ++++++----- src/client/io_loop.rs | 8 ++++++-- src/port_forward.rs | 4 +++- src/ui_session_interface.rs | 4 ++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/client.rs b/src/client.rs index 5f5f34cd0c6..e1e4c803443 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3548,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 @@ -3572,7 +3572,7 @@ pub async fn handle_hash( 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; } } } @@ -3587,7 +3587,7 @@ pub async fn handle_hash( let mut msg = Message::new(); msg.set_misc(misc); allow_err!(peer.send(&msg).await); - return; + return false; } } // last password @@ -3651,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() { @@ -3677,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] @@ -3804,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, diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 4636c54f802..1af691429dd 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1353,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)) => { diff --git a/src/port_forward.rs b/src/port_forward.rs index 8b190fb1e37..7a3f8715ccb 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -150,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/ui_session_interface.rs b/src/ui_session_interface.rs index bf2e04c6ba7..9e4128dca79 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1878,8 +1878,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( From ff07ff7f13a7c4a350519243b803759207978817 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:54:57 +0800 Subject: [PATCH 112/121] =?UTF-8?q?fix(terminal):=20send=20SGR=20mouse=20w?= =?UTF-8?q?heel=20reports=20with=20the=20button=20codes=20app=E2=80=A6=20(?= =?UTF-8?q?#15817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): send SGR mouse wheel reports with the button codes apps expect xterm.dart 4.0.0 encodes the wheel buttons as 64+4..64+7 rather than 64+0..64+3, so the low bits land on the modifier field and every wheel report the terminal emits reads as wheel-with-Shift. Strict full-screen applications reject the modified event, which is why neither the mouse wheel nor the trackpad scrolls anything once the peer application takes over the alternate screen. Install a mouse handler that keeps every upstream reporting decision and only re-encodes the wheel buttons as 64..67. Non-wheel reports pass through untouched, and the emitted bytes stay identical once upstream ships the same fix, so this can be dropped without a behavior change. Upstream: TerminalStudio/xterm.dart#238 Co-Authored-By: Claude Fable 5 * fix(terminal): correct the wheel report row, drop the wasted report build Address review feedback on the wheel button fix: - The X10/utf row was encoded as `32 + y + 1` while y is already 1-based, so every normal-mode report pointed one row too low and the `y > limit` guard disagreed with what it emitted. - Gate the wheel path on `mouseMode.reportScroll` and the button state instead of building and discarding a full report string from `defaultMouseHandler` on every scroll tick. This also makes the hardcoded SGR 'M' provably right, since a wheel release now returns before the report is built. - Derive the wire code as `id - 4` and drop `_wheelButtonId`, whose `default` branch was unreachable and defeated enum exhaustiveness. - Assign `mouseHandler` after construction so the `Terminal(...)` line stays untouched. Cover the utf, urxvt, null-byte overflow and click-only branches, and assert that TerminalModel actually installs the handler. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- flutter/lib/models/terminal_model.dart | 2 + .../lib/models/terminal_mouse_handler.dart | 42 +++++++ .../test/terminal_model_lifecycle_test.dart | 17 +++ flutter/test/terminal_mouse_handler_test.dart | 114 ++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 flutter/lib/models/terminal_mouse_handler.dart create mode 100644 flutter/test/terminal_mouse_handler_test.dart diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 6f179afe299..0472cb48373 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -10,6 +10,7 @@ import 'package:xterm/xterm.dart'; import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; +import 'terminal_mouse_handler.dart'; class TerminalModel with ChangeNotifier { final String id; // peer id @@ -129,6 +130,7 @@ class TerminalModel with ChangeNotifier { TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { terminal = Terminal(maxLines: 10000); + terminal.mouseHandler = const WheelButtonFixMouseHandler(); terminalController = TerminalController(); // Setup terminal callbacks 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/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart index d00646b2ba9..5581886b709 100644 --- a/flutter/test/terminal_model_lifecycle_test.dart +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -3,6 +3,7 @@ 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 @@ -48,4 +49,20 @@ void main() { 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, + ); + }); +} From 23256e6ac1687ba63c38a3d55a64e1b04c8c637a Mon Sep 17 00:00:00 2001 From: "Chen, Ting-An" <73953029+nrps9909@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:00:05 +0800 Subject: [PATCH 113/121] fix(i18n): complete Traditional Chinese sign-in strings (#15829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 陳廷安 <73953029+nrps9909@users.noreply.github.com> --- src/lang/tw.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 0401d80b71c..438cb809143 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -773,7 +773,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("Continue", "繼續"), + ("Browser didn't open? Use the url below to sign in.", "瀏覽器未開啟?請使用下方網址登入。"), ].iter().cloned().collect(); } From 1d09760ef7c9275555ac512d66fee5af549c5d06 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 11 Aug 2026 15:54:03 +0800 Subject: [PATCH 114/121] fix(terminal): keep selection aligned after clearing scrollback (#15831) Remove scrollback lines through the index-aware buffer operation so deleted anchors are detached and retained lines are reindexed. Signed-off-by: fufesou --- flutter/lib/models/rustdesk_terminal.dart | 14 ++++++++++++++ flutter/lib/models/terminal_model.dart | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 flutter/lib/models/rustdesk_terminal.dart 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 0472cb48373..63e83120236 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -10,6 +10,7 @@ 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 { @@ -129,7 +130,7 @@ 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(); From 63822048df9c86ecb3b6bd9000a130a7a0919f2f Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Wed, 12 Aug 2026 12:00:43 +0530 Subject: [PATCH 115/121] fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834) FUSE-Rust: Uninitalized memory read and leak caused by fuser crate Resolves GHSA-cvmj-47v9-35m9 Signed-off-by: anupamme --- Cargo.lock | 4 ++-- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93e1a683712..479307ec39f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +checksum = "0bb29a3ae32279fe3e79a958fe01899f5fb23eadccee919cf88e145b54ed9367" dependencies = [ "libc", "log", diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f3137..9bb5e789f94 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 = {version = "0.16", 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} From 10bcf976f7cf3ebe0a4e196dbd2b1c89e85dcac7 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:12:46 +0800 Subject: [PATCH 116/121] Revert "fix: upgrade fuser to 0.16.0 (GHSA-cvmj-47v9-35m9) (#15834)" (#15841) This reverts commit 63822048df9c86ecb3b6bd9000a130a7a0919f2f. --- Cargo.lock | 4 ++-- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 479307ec39f..93e1a683712 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.16.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb29a3ae32279fe3e79a958fe01899f5fb23eadccee919cf88e145b54ed9367" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" dependencies = [ "libc", "log", diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index 9bb5e789f94..afe2f2f3137 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.16", default-features = false, optional = true} +fuser = {version = "0.15", 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} From dfca2c1b8f401b24c231cca75b155ed6aa3b518c Mon Sep 17 00:00:00 2001 From: rustdesk Date: Wed, 12 Aug 2026 17:28:59 +0800 Subject: [PATCH 117/121] update agents.md --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4ff5b1e75fe..7ab98087d8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,6 @@ * Keep them short: one line by default, three at most. * Say **why**, never what. If the code already says it, delete the comment. -* Do not document rejected alternatives, past bugs, measurements, or how you arrived at the code. That belongs in the commit message or the PR. * A comment must never be longer than the code it describes. * Applies to YAML, shell and Python too, not just Rust. From c4fd7d692dc657e3bc87e1f75f3308e9cd426987 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 12 Aug 2026 21:36:06 +0800 Subject: [PATCH 118/121] refact: fuser 0.16.0, cargo 1.75.0 (#15844) Signed-off-by: fufesou --- Cargo.lock | 5 ++--- libs/clipboard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93e1a683712..cb08cdad2c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3074,9 +3074,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", 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} From d829d1410a49123fcf4209496a4788f4cc02eec5 Mon Sep 17 00:00:00 2001 From: Mariano Abad Date: Thu, 13 Aug 2026 09:22:41 -0300 Subject: [PATCH 119/121] fix(linux): serve the Wayland login screen the DRM backend was built for (#15792) * fix(linux): serve the Wayland login screen the DRM backend was built for The login screen support in #15420 never worked on a real greeter. fufesou found it: the session is refused, and with the refusal commented out the client gets a failed connection instead of a screen. One premise under all of it. `get_values_of_seat0` is `_get_values_of_seat0(.., ignore_gdm_wayland = true)`, so a gdm/sddm Wayland session is skipped by construction and `get_display_server` falls back to x11. That was correct while the portal was the only backend, since the portal cannot serve a greeter at all. The DRM path never talks to the compositor, which is precisely why it can serve one, so the premise stops holding there and every x11-vs-Wayland decision in the tree answers x11 at a login screen. The central change is the memoised `IS_X11`: when it reads x11 and seat0 is a Wayland greeter, answer Wayland. That covers fifteen routing sites at once, and it is under `cfg(feature = "drm")`, so a build without the backend keeps the current answer exactly. `is_x11_for_drm` is the unmemoised form for the two retry loops that must keep asking while a boot is still naming the session, and the memoised accessor is scoped to per-frame callers in the per-session `--server`, which the service only spawns once it has identified the session. Input was the last layer and lived outside all of that. `Enigo` decides x11-vs-Wayland once in `Default::default()`, from the same seat0 lookup, and on "x11" routes every key and mouse event to xdo; with no X server that context is null and libxdo drops them without an error. So the uinput devices were created, the compositor opened them, and nothing was ever written to them. `set_is_x11` is now called where the custom devices are installed, which is only reached once `!is_x11()` is already established. The unit test pins both directions, since a one-directional test passes against the bug. With no compositor reachable, the uinput desktop rect comes from the DRM display list instead: those are the same displays being captured, so the coordinate space matches by construction. Telling the truth about a greeter also makes four compositor-probing paths reachable where the probe cannot answer; all four already treat an empty output list as "nothing to do", so they skip it and 11818 "Could not find wayland compositor" warnings in one session became 1. Tested on an sddm Plasma Wayland greeter, MacBook T2, 2880x1800: the greeter renders, typing from the client enters characters in the password field, a click at an absolute coordinate opens the greeter session combo, the service pre-warm primes in 994 us instead of timing out, and the privileged service maps no EGL during a live capture. Not proven on gdm under Wayland. Known limitations: non-ASCII characters cannot be typed at a greeter, because that path goes through the clipboard and the clipboard here is X11 only; and at a multi-monitor greeter the pointer reaches the first display only, since every DRM output reports origin (0,0) on Wayland and there is no arrangement to derive without the compositor. * fix(linux): a Wayland greeter the DRM backend can serve is not headless fufesou reported the login screen still failing on Ubuntu 24.04 with gdm3, with the client asking for OS credentials to start an X session instead of showing the greeter. Reproduced on a real gdm greeter here. Same premise as the rest of the branch, one more consumer. `DesktopManager::new` reads seat0 through `get_values_of_seat0`, which skips a gdm/sddm Wayland session by construction, so at a greeter it finds no session at all and `get_supported_display_seat0_username` returns None from its empty-username arm. That makes `is_headless()` true, so the service advertises headless and `try_start_desktop` answers `LOGIN_MSG_DESKTOP_SESSION_NOT_READY`. The corrected `IS_X11` does not reach this one: it asks who owns seat0, not which display server is running. So ask again, with the greeter visible, when the DRM backend can capture and inject into it. At query time rather than in `new()`, because the DRM probe has not necessarily settled when the desktop manager is constructed, and the answer would latch for the process lifetime. In a normal session the latched username is a real user and the extra read is skipped. * chore: drop the hbb_common bump, this branch does not need it The bump carried rustdesk/hbb_common#580, the compositor-socket fallback. Nothing here depends on it: the greeter paths in this branch are the ones that run when compositor data is unavailable, which is what the commit before this one states as a known limitation. Keeping the bump would only block the greeter fix behind a review of a separate change, and would import that change's blocking review items into this path. * fix(linux): let the uinput uid gate see the greeter that owns seat0 Input at a real greeter was rejected by our own authorization. Measured on Ubuntu 24.04 with gdm3: the root service logs Rejected unauthorized connection on uinput ipc channel: postfix=_uinput_control, peer_uid=Some(120), active_uid=None and the greeter's `--server` gets ECONNRESET out of `setup_uinput`, so no uinput device is ever created and neither keyboard nor mouse reaches the greeter. uid 120 is gdm, the owner of the only active seat0 session. `active_uid` is None because the uinput authorizer deliberately bypasses the service-loop cache and takes a fresh seat0 lookup, and the fresh read hides a Wayland greeter by construction. The cache-based gates do not have the problem: `Desktop::refresh` fills it through the greeter-visible read, which is also why capture and config sync work at a greeter while input does not. So make the fresh read agree with the cache. It keeps the property the uinput gate wants, a lookup that cannot be stale, and it still compares the peer against the uid of the session that owns seat0 -- which at a greeter is the greeter. * fix: settle the DRM probe before routing login to X11, and read seat0 fresh Two findings from the #15792 review, both verified against the code: - drm_login_screen_seat0_username asked the cached probe, so a client arriving before warm_availability publishes its verdict read "no DRM" and, with allow-linux-headless=Y, try_start_x_session could start Xorg over a live Wayland greeter. Ask the probing form instead, and only after the cheap seat0 read says a Wayland greeter is actually there: a bounded definitive verdict is affordable on a login-time path. - get_supported_display_seat0_username trusted the seat0 values cached in DesktopManager::new(), which go stale across a logout or a fast user switch: a stale non-greeter name skipped the greeter probe and was returned as the supported display owner. Read seat0 fresh on every query; every call site is connection-time, so the extra loginctl read is cheap. Regression-tested on a real sddm Wayland greeter: capture streams the greeter, the RustDesk password dialog is the only prompt, and five typed characters appeared in the greeter password field over uinput with zero "Rejected unauthorized connection" lines in the service log. * fix: ask the greeter compositor for the multi-monitor layout The display arrangement and the pointer mapping were wrong at a multi-monitor login screen, and the mechanism is measured on a two-head virtio VM: DRM has no origins, so every display was advertised at (0,0) (a stacked arrangement on the client), and the uinput range was taken from the union of the DRM modes while the compositor had arranged the outputs side by side. Both came from the same premise, written before the hbb_common socket fallback existed: "a login screen has no compositor to ask". wayland_outputs_askable() skipped the wl_output augmentation at any greeter, and update_uinput_resolution took the DRM union directly. The premise is false now: a greeter runs a compositor, and the socket fallback reaches it with no environment variables, measured answering two outputs at the VM greeter while the old gate was still routing around it. Drop the gate and take the compositor-first path everywhere. Where the fallback cannot answer, the output list comes back empty and both call sites degrade to exactly the old behavior, so a build against an older hbb_common is unchanged. * fix: augment a single display too, and probe the desktop rect off the executor Two follow-ups from the automated re-review of cd80c3dee, both verified: - augment_with_wayland_geometry skipped the compositor below two DRM displays, but on a multi-GPU host the one connector this service can open may sit at a non-zero origin of the compositor layout, and DRM alone reports (0,0). - the desktop rect for uinput can now block for the socket probe deadline, and update_uinput_resolution runs on current-thread runtimes; move the query into spawn_blocking. The third re-review finding, the warm-up allegedly skipping Wayland greeters, is refuted: warm_availability probes while is_x11_for_drm() is false, which includes a Wayland greeter, and the greeter log of the VM run behind cd80c3dee shows the warm succeeding there. * fix: baseline the layout from the blocking task, and augment a lone output's origin The layout snapshot after the rect lookup still ran on the executor: a failed compositor lookup is not cached, so the snapshot synchronously repeated the whole socket probe there. The baseline is now computed inside the same blocking task, from the snapshot the successful lookup just cached, or omitted when only the raw DRM union was available, which keeps the #15601 remap inactive exactly where origins are unknown. A single compositor output now hands its origin to a single connector: the lone output can sit at a non-zero origin the DRM side cannot see. Scale stays 1 on purpose, matching how a single display is advertised at physical size, and more connectors than the one output stays unaugmented, since the layout-order fallback would plant that origin on a guess. Also refresh the get_primary_index doc that still said augmentation declines below two connectors. * fix: read the DRM probe as a tri-state, and keep pre-auth seat0 checks cache-only is_available() answered false both for a definitive no-DRM verdict and for a probe that had simply not settled (another probe in flight, or a failure still below the disable threshold), and the login-screen decision turned that transient false into no-greeter: try_start_x_session could put Xorg over a live greeter in exactly the window the probe needed. The machinery now answers Available/Unavailable/Unsettled, and only a definitive Unavailable routes the seat toward X11. Connection setup also ran the whole lookup pre-auth: constructing LinuxHeadlessHandle called is_headless() before authentication, holding DESKTOP_MANAGER while loginctl ran and, at a greeter, while the DRM probe waited out its handshake. An unauthenticated peer could occupy a worker for seconds and serialize every other connection on the mutex. is_headless() now answers from a snapshot refreshed off-thread, and the fresh lookup became a free function called with the manager lock released everywhere; the enforcing decisions, get_username and try_start_x_session, still read seat0 fresh. Also drops seat0_display_server, dead since the fresh-read change. * fix: respect RUSTDESK_FORCED_DISPLAY_SERVER over the greeter correction The greeter correction rewired IS_X11 and is_x11_for_drm() to Wayland whenever seat0 looks like a Wayland greeter, including when the operator explicitly forced the display server: get_display_server() kept honoring the override while the DRM routing gates contradicted it, leaving capture and input routing internally inconsistent. The correction now only adjusts the auto-detected answer. * fix: honest pre-auth snapshot, sticky negative verdict, and a complete forced-x11 gate Four defects found by an adversarial review of the two previous commits, all in their new lines: - The empty-snapshot fallback derived headless from the manager's boot-time seat0 read, which is blank at a Wayland greeter (the loginctl wrapper skips greeter sessions), so the first connection of every server process at a greeter answered headless=true, the opposite of the comment on it. No snapshot now answers NOT headless, the snapshot is seeded at start_xdesktop, and the boot-time cache is gone entirely (it had no reader left). - wait_desktop_cm_ready gated on a bool stored at construction, which can lag one seat0 transition behind and skipped the CM-ready wait right after a logout. It re-reads the snapshot at call time. - A settled Unavailable was erased at NEGATIVE_TTL expiry (state to Unknown, failure counter to zero), so a permanently helper-less box reopened the Unsettled window every 30 seconds and the login decision kept adopting a greeter nothing can serve. The verdict now stays Unavailable while an off-thread re-probe re-verifies it: a failed or empty re-probe restamps the no, and only a non-empty list flips it. - The forced-x11 gate only covered IS_X11 and is_x11_for_drm, while the seat0 adoption path still probed DRM and admitted greeter sessions whose capture and input then routed to X11. Greeter adoption now yields to an operator-forced X11, degrading to upstream behavior: the connection is refused at the login screen. * fix: keep the login request path off the probe entirely try_start_desktop runs while handling a LoginRequest, before password validation, and at a Wayland greeter its seat0 lookup reached the probing availability form: an unauthenticated peer could park a worker for the probe deadline. The greeter adoption now reads a cached tri-state that never blocks; when the state is Unknown it kicks the probe off-thread and answers Unsettled, which the login decision treats as a possibly servable greeter until it settles. Settling lives in the startup warm-up, that kick, and the TTL re-verifiers; the blocking form stays for the capture-side callers, where waiting is acceptable. * fix: run the pre-auth desktop start off the executor, guard the refresh flag, trim comments From fufesou's #15792 re-review (no blocking issues) plus a bot pass: - try_start_desktop now runs on spawn_blocking. It executes loginctl, and PAM when a session must start, while handling a LoginRequest before password validation, so a slow logind must not tie up an async request worker; the blocking pool absorbs it. - kick_seat0_refresh releases SEAT0_REFRESH_IN_FLIGHT through an RAII guard, so a panic in the refresh thread cannot freeze is_headless on a stale snapshot for the process lifetime. - drm_can_serve_login_screen stays Available-only, and the reason is now in the code: it is deliberately not symmetric with the seat0 adoption gate. Adoption yields Xorg only on a definitive Unavailable; admission accepts only on a definitive Available; both wait through an unsettled probe. Admitting there would black-screen a client on a helper-less box, so a review suggestion to make them agree is declined. - Trimmed two over-long comments to the repo's three-line rule. * fix(linux): harden DRM login-screen startup Keep unauthenticated headless checks cache-only, bound OS-session startup to one blocking task, and surface JoinError failures. Wire the isolated Wayland probe consumer and update hbb_common plus libdrmtap 0.5.4. * fix(linux): headless refresh state Signed-off-by: fufesou * fix(linux): keep headless startup state consistent - gate concurrent desktop startup attempts - route CM IPC after refreshing desktop state - avoid blocking seat0 queries in the CM retry loop - preserve newer seat0 snapshots during overlapping refreshes - derive DRM geometry and primary display from one Wayland snapshot Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com> Co-authored-by: rustdesk Co-authored-by: fufesou --- build.py | 4 +- libs/enigo/src/linux/nix_impl.rs | 56 +++++ libs/hbb_common | 2 +- libs/scrap/Cargo.toml | 4 +- src/common.rs | 2 + src/ipc/drm.rs | 5 +- src/platform/linux.rs | 73 ++++++- src/platform/linux_desktop_manager.rs | 266 +++++++++++++++++++---- src/server/connection.rs | 137 +++++++++--- src/server/display_service.rs | 22 ++ src/server/drm_capturer.rs | 300 +++++++++++++++++++++----- src/server/input_service.rs | 15 +- src/server/wayland.rs | 68 +++++- 13 files changed, 806 insertions(+), 148 deletions(-) diff --git a/build.py b/build.py index b32e95672f8..6b770f993e5 100755 --- a/build.py +++ b/build.py @@ -390,9 +390,9 @@ def ffi_bindgen_function_refactor(): # 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.2. +# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4. LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap' -LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21' +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 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 69cea8dafee..f124c0a5d49 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 +Subproject commit f124c0a5d49a4a13381902124b65364ff28fa541 diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index da056b46d62..bab2b4e9f2c 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -14,13 +14,13 @@ wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", " # `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.2). We deliberately do +# `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"] +drm = ["wayland", "hbb_common/wayland_probe"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/src/common.rs b/src/common.rs index 592ab2a45e3..09fa1b4ca61 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() { diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs index c2c399e6fe5..15e500e6070 100644 --- a/src/ipc/drm.rs +++ b/src/ipc/drm.rs @@ -641,11 +641,12 @@ fn drm_udev_listener() { 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). `scrap::is_x11()` is the UNMEMOISED path. + // "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 scrap::is_x11() { + 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 \ diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 68a005ff75b..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)); @@ -1062,6 +1128,11 @@ 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() } 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/server/connection.rs b/src/server/connection.rs index b461a3effb9..bf05d56bd74 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -122,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")] @@ -464,6 +476,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, @@ -1967,7 +1997,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(); @@ -2883,6 +2914,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(); } @@ -2900,9 +2932,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 @@ -2923,6 +2964,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. @@ -6221,20 +6268,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(); @@ -6652,10 +6699,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<()>, @@ -6665,31 +6732,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; } diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 3647d7ee6e2..7572caf10cf 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -100,6 +100,11 @@ 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 { @@ -484,6 +489,22 @@ 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`. @@ -491,6 +512,7 @@ pub(super) fn update_sync_displays(all: &Vec) -> Vec { #[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() diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs index d447715df2d..0c6beb49315 100644 --- a/src/server/drm_capturer.rs +++ b/src/server/drm_capturer.rs @@ -823,40 +823,110 @@ impl Drop for UinputRefreshGuard { /// 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(super) fn is_available_cached() -> bool { +pub(crate) fn is_available_cached() -> bool { matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) } -/// MAY BLOCK for seconds: never a routing gate. -pub(super) fn is_available() -> bool { - let verdict = { - let mut st = DRM_STATE.lock().unwrap(); - if let ProbeState::Unavailable(since) = &*st { - if since.elapsed() >= NEGATIVE_TTL { - publish_probe_state(&mut st, ProbeState::Unknown); - DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); +/// 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)) } - } - match &*st { - ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), - ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), ProbeState::Unknown => None, // fall through and probe with the lock released - } + }; + (verdict, stale_no) }; - if let Some((available, stale)) = verdict { + if let Some((answer, stale)) = verdict { if stale { refresh_available_async(); } - return available; + if stale_no { + refresh_unavailable_async(); + } + return answer; } if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { - return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + // 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 available = match result { + let answer = match result { Ok(list) if !list.is_empty() => { log::debug!( "drm: availability probe -> available ({} displays) in {:?}", @@ -865,28 +935,84 @@ pub(super) fn is_available() -> bool { ); DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); - true + Availability::Available } Ok(_) => { log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); - false + 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 } - false } }; drop(st); - available + 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() { @@ -963,9 +1089,10 @@ fn refresh_available_async() { 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. `scrap::is_x11()` is the UNMEMOISED form. + // 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 scrap::is_x11() { + if crate::platform::linux::is_x11_for_drm() { std::thread::sleep(Duration::from_millis(300)); continue; } @@ -1059,64 +1186,99 @@ pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { Some((len, any_demoted)) } -/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. -pub(super) fn get_display_infos() -> Option> { +// 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 multi = list.len() > 1; - let mut infos = augment_with_wayland_geometry(&list); - // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host - // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so - // the index space stays aligned with get_capturer_info(). A single-display host stays online. - if multi { - let health = DRM_DISPLAY_HEALTH.lock().unwrap(); - for (idx, info) in infos.iter_mut().enumerate() { - let key = match list.get(idx) { - Some(d) => connector_key(d), - None => continue, - }; - if health.get(&key).is_some_and(|h| h.demoted()) { - info.online = false; - } - } - } - Some(infos) + 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)) } -/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes -/// the advertised primary and geometry agree, but not below two connectors or two outputs, where -/// `augment_with_wayland_geometry` declines to run the assignment. -pub(super) fn get_primary_index() -> usize { +pub(super) fn get_display_infos() -> Option> { let list = match &*DRM_STATE.lock().unwrap() { ProbeState::Available(_, list) => list.clone(), - _ => return 0, + _ => return None, }; - let wl = scrap::wayland::display::get_displays(); - if wl.displays.is_empty() { - return 0; - } - assign_wayland_outputs(&list, &wl.displays) - .iter() - .position(|assigned| *assigned == Some(wl.primary)) - .unwrap_or(0) + 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(); - if drm.len() < 2 || wl.displays.len() < 2 { + // 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; } - let matched = assign_wayland_outputs(drm, &wl.displays); for (i, info) in infos.iter_mut().enumerate() { let Some(w) = matched[i].map(|j| &wl.displays[j]) else { continue; }; info.x = w.x; info.y = w.y; + 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; @@ -1487,6 +1649,26 @@ mod drm_capturer_tests { } } + #[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))); diff --git a/src/server/input_service.rs b/src/server/input_service.rs index aa6893f3942..f8f943276b0 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -663,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")?; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index ffdf12c9821..023e9e55947 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,6 +107,25 @@ 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 @@ -134,17 +153,41 @@ pub(super) async fn update_uinput_resolution() { if !crate::input_service::wayland_use_uinput() { return; } - scrap::wayland::display::clear_wayland_displays_cache(); - let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { - log::warn!("Failed to get desktop rect for 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( - scrap::wayland::display::get_display_rects_for_uinput(), - ); + 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 @@ -331,10 +374,13 @@ pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, // 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; - if let Some(displays) = super::drm_capturer::get_display_infos() { - // DRM connector order is not the compositor's primary; resolve the real primary from - // the compositor layout (matched by normalized connector name), not a hardcoded index 0. - return Ok((displays, super::drm_capturer::get_primary_index())); + 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); } } check_init().await?; From d1da05c4dbf3e4f3f87c89da3ddfb2614164a3b3 Mon Sep 17 00:00:00 2001 From: fufesou Date: Fri, 14 Aug 2026 14:31:13 +0800 Subject: [PATCH 120/121] refact: remove feature plugin-framework (#15854) * refact: remove feature plugin-framework Signed-off-by: fufesou * refact: remove unused translations Signed-off-by: fufesou * fix: delete settings tab observable with correct type Signed-off-by: fufesou --------- Signed-off-by: fufesou --- Cargo.lock | 100 +-- Cargo.toml | 4 +- .../lib/desktop/pages/desktop_home_page.dart | 17 - .../desktop/pages/desktop_setting_page.dart | 59 +- flutter/lib/desktop/pages/remote_page.dart | 1 - .../lib/desktop/pages/view_camera_page.dart | 1 - .../lib/desktop/widgets/remote_toolbar.dart | 14 +- flutter/lib/main.dart | 11 - flutter/lib/models/model.dart | 12 - flutter/lib/plugin/common.dart | 42 -- flutter/lib/plugin/event.dart | 18 - flutter/lib/plugin/handlers.dart | 79 --- flutter/lib/plugin/manager.dart | 319 --------- flutter/lib/plugin/model.dart | 110 --- flutter/lib/plugin/ui_manager.dart | 17 - flutter/lib/plugin/utils/dialogs.dart | 86 --- flutter/lib/plugin/widgets/desc_ui.dart | 301 -------- .../lib/plugin/widgets/desktop_settings.dart | 202 ------ flutter/lib/web/bridge.dart | 72 -- flutter/lib/web/plugin/handlers.dart | 14 - src/client/io_loop.rs | 28 - src/core_main.rs | 36 - src/flutter.rs | 43 -- src/flutter_ffi.rs | 177 ----- src/ipc.rs | 9 - src/lang/ar.rs | 3 - src/lang/be.rs | 3 - src/lang/bg.rs | 3 - src/lang/ca.rs | 3 - src/lang/cn.rs | 3 - src/lang/cs.rs | 3 - src/lang/da.rs | 3 - src/lang/de.rs | 3 - src/lang/el.rs | 3 - src/lang/eo.rs | 3 - src/lang/es.rs | 3 - src/lang/et.rs | 3 - src/lang/eu.rs | 3 - src/lang/fa.rs | 3 - src/lang/fi.rs | 3 - src/lang/fr.rs | 3 - src/lang/ge.rs | 3 - src/lang/gu.rs | 3 - src/lang/he.rs | 3 - src/lang/hi.rs | 3 - src/lang/hr.rs | 3 - src/lang/hu.rs | 3 - src/lang/id.rs | 3 - src/lang/it.rs | 3 - src/lang/ja.rs | 3 - src/lang/ko.rs | 3 - src/lang/kz.rs | 3 - src/lang/lt.rs | 3 - src/lang/lv.rs | 3 - src/lang/ml.rs | 3 - src/lang/nb.rs | 3 - src/lang/nl.rs | 3 - src/lang/pl.rs | 3 - src/lang/pt_PT.rs | 3 - src/lang/ptbr.rs | 3 - src/lang/ro.rs | 3 - src/lang/ru.rs | 3 - src/lang/sc.rs | 3 - src/lang/sk.rs | 3 - src/lang/sl.rs | 3 - src/lang/sq.rs | 3 - src/lang/sr.rs | 3 - src/lang/sv.rs | 3 - src/lang/ta.rs | 3 - src/lang/template.rs | 3 - src/lang/th.rs | 3 - src/lang/tr.rs | 3 - src/lang/tw.rs | 3 - src/lang/uk.rs | 3 - src/lang/vi.rs | 3 - src/lib.rs | 4 - src/plugin/callback_ext.rs | 44 -- src/plugin/callback_msg.rs | 411 ----------- src/plugin/config.rs | 363 ---------- src/plugin/desc.rs | 100 --- src/plugin/errno.rs | 50 -- src/plugin/ipc.rs | 230 ------ src/plugin/manager.rs | 600 ---------------- src/plugin/mod.rs | 188 ----- src/plugin/native.rs | 40 -- src/plugin/native_handlers/macros.rs | 27 - src/plugin/native_handlers/mod.rs | 126 ---- src/plugin/native_handlers/session.rs | 219 ------ src/plugin/native_handlers/ui.rs | 143 ---- src/plugin/plog.rs | 34 - src/plugin/plugins.rs | 659 ------------------ src/server/connection.rs | 89 --- src/ui_session_interface.rs | 10 - 93 files changed, 8 insertions(+), 5251 deletions(-) delete mode 100644 flutter/lib/plugin/common.dart delete mode 100644 flutter/lib/plugin/event.dart delete mode 100644 flutter/lib/plugin/handlers.dart delete mode 100644 flutter/lib/plugin/manager.dart delete mode 100644 flutter/lib/plugin/model.dart delete mode 100644 flutter/lib/plugin/ui_manager.dart delete mode 100644 flutter/lib/plugin/utils/dialogs.dart delete mode 100644 flutter/lib/plugin/widgets/desc_ui.dart delete mode 100644 flutter/lib/plugin/widgets/desktop_settings.dart delete mode 100644 flutter/lib/web/plugin/handlers.dart delete mode 100644 src/plugin/callback_ext.rs delete mode 100644 src/plugin/callback_msg.rs delete mode 100644 src/plugin/config.rs delete mode 100644 src/plugin/desc.rs delete mode 100644 src/plugin/errno.rs delete mode 100644 src/plugin/ipc.rs delete mode 100644 src/plugin/manager.rs delete mode 100644 src/plugin/mod.rs delete mode 100644 src/plugin/native.rs delete mode 100644 src/plugin/native_handlers/macros.rs delete mode 100644 src/plugin/native_handlers/mod.rs delete mode 100644 src/plugin/native_handlers/session.rs delete mode 100644 src/plugin/native_handlers/ui.rs delete mode 100644 src/plugin/plog.rs delete mode 100644 src/plugin/plugins.rs diff --git a/Cargo.lock b/Cargo.lock index cb08cdad2c7..9272b562adb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,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" @@ -1477,8 +1456,8 @@ dependencies = [ "compression-core", "flate2", "memchr", - "zstd 0.13.1", - "zstd-safe 7.1.0", + "zstd", + "zstd-safe", ] [[package]] @@ -1549,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" @@ -3825,7 +3798,7 @@ dependencies = [ "whoami", "winapi 0.3.9", "x11 2.21.0", - "zstd 0.13.1", + "zstd", ] [[package]] @@ -6057,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" @@ -7365,7 +7315,6 @@ dependencies = [ "wol-rs", "x11-clipboard 0.8.1", "x11rb 0.12.0", - "zip", ] [[package]] @@ -8907,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", @@ -11157,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 2fac88c0057..588cbd96aed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ drm = ["scrap/drm"] # 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"] -plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ "dep:x11-clipboard", @@ -81,7 +80,6 @@ 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" @@ -212,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) 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 b2aab1cfbbb..a2eb94e420b 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') @@ -171,7 +166,7 @@ class _DesktopSettingPageState extends State void dispose() { super.dispose(); Get.delete(tag: _kSettingPageControllerTag); - Get.delete(tag: _kSettingPageTabKeyTag); + Get.delete>(tag: _kSettingPageTabKeyTag); WidgetsBinding.instance.removeObserver(this); _videoConnTimer?.cancel(); } @@ -196,10 +191,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 +224,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; @@ -2255,51 +2243,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}); diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index a9185d6a309..79f382249d2 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -182,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((_) { 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 2373d016a98..0516608cdee 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,13 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - final Widget pluginItem; _DisplayMenu( {Key? key, required this.id, required this.ffi, required this.state, required this.setFullscreen}) - : pluginItem = LocationItem.createLocationItem( - id, - ffi, - kLocationClientRemoteToolbarDisplay, - true, - ), - super(key: key); + : super(key: key); @override State<_DisplayMenu> createState() => _DisplayMenuState(); @@ -1582,9 +1573,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { ]); } } - if (ffi.connType == ConnType.defaultConn) { - menuChildren.add(widget.pluginItem); - } return menuChildren; } diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 7e0a8cb2b71..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 { diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 4a6088bd3e6..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; @@ -437,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']; 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/web/bridge.dart b/flutter/lib/web/bridge.dart index ac48dfb0ffd..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'); } 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/src/client/io_loop.rs b/src/client/io_loop.rs index 1af691429dd..bc1828fd872 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1437,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() { @@ -1988,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; diff --git a/src/core_main.rs b/src/core_main.rs index b20ecd92be5..3a190f1148b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -190,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")] { @@ -737,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()); @@ -762,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 f6e3d3edd91..87c9c02af83 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -225,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, } @@ -236,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()), ), @@ -636,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); @@ -1194,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(); @@ -1963,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()) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9b73c4cd4a1..091fcef25a5 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, @@ -2522,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)) } diff --git a/src/ipc.rs b/src/ipc.rs index 52e79955d38..9e3faab63fa 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -19,9 +19,6 @@ 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, @@ -404,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)), @@ -1076,9 +1070,6 @@ async fn handle(data: Data, stream: &mut Connection) { .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!( diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2189648d94d..bc9ea67d8eb 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -483,9 +483,6 @@ 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", "تفعيل"), diff --git a/src/lang/be.rs b/src/lang/be.rs index ac302f3af48..3dd0ef75ecf 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -483,9 +483,6 @@ 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", "Уключыць"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index c339270c0a8..0d926db31b1 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -483,9 +483,6 @@ 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", "Позволяване"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index d3b0ae7e0cc..17f5817b29e 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 7423cceb331..d1ada573bf0 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -483,9 +483,6 @@ 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", "启用"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index abd4e60aa79..3bb59ecfba8 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 0ecab9098c1..823a58a6b90 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/de.rs b/src/lang/de.rs index d71dfa6ce33..7f30d1051e4 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/el.rs b/src/lang/el.rs index deca79aa6fd..e6c4ab6fe96 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -483,9 +483,6 @@ 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", "Ενεργοποίηση"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index e6cc0cae523..5e5c19d640c 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 2e7ace9cf02..7f12ef41ed9 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/et.rs b/src/lang/et.rs index 238c84c88e4..d8cd510bb50 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 3fd38eb553e..838b263535a 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 1e4039be7d0..08dc9f568c2 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -483,9 +483,6 @@ 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", "فعال کردن"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 2a21ba04964..d86cad0c724 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 8359587a20c..a20c0e41b5f 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 97c3e9171eb..ce6e89bb4bd 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -483,9 +483,6 @@ 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", "ჩართვა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index c9c2c9177f0..31e905ea7e9 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -483,9 +483,6 @@ 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", "સક્ષમ કરો"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 3ea0d762615..b82f66dab0b 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -483,9 +483,6 @@ 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", "פועל"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index e3851a0d85b..1e5d3a0b5f1 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -483,9 +483,6 @@ 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", "सक्षम करें"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index ee894b0e7d8..a054622920e 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 14a85f1f7d8..68d26c38904 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/id.rs b/src/lang/id.rs index 7ba387e485a..c51c908a432 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 1297972dffb..939048e3ba6 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index ba6e6cb0956..2d944bf8fd5 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -483,9 +483,6 @@ 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", "有効"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f60af542b20..d46c327d3cf 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -483,9 +483,6 @@ 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", "허용"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index fc59efde3cf..b73b8dac0dc 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -483,9 +483,6 @@ 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", "Қосу"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 3589a2fb31b..5c26e3119e5 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index d4101d6dbf2..7ae3178938e 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index d93760b508a..69394909b85 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -483,9 +483,6 @@ 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", "പ്രവർത്തനക്ഷമമാക്കുക"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 3cc71a96b2d..cf0009314b4 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 61a5306c9ee..68206f0d686 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -483,9 +483,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Vingerafdruk"), ("Copy Fingerprint", "Vingerafdruk kopiëren"), ("no fingerprints", "geen vingerafdrukken"), - ("Select a peer", "Selecteer een peer"), - ("Select peers", "Selecteer peers"), - ("Plugins", "Plugins"), ("Uninstall", "Verwijderen"), ("Update", "Bijwerken"), ("Enable", "Activeren"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index df5c53439ec..0e2e03f0223 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 79420e73be1..a391fcfdc6d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 61bf5cf484c..522b3f8b824 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 4499df1bdfd..cc774057ef4 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -483,9 +483,6 @@ 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ă"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 459549f97ea..2ded4ebf575 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -483,9 +483,6 @@ 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", "Включить"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1ccfcf7dc78..6bb1190c1d2 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 3d499311590..96eb423efc8 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -483,9 +483,6 @@ 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ť"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 10fc5d909c9..82f17742811 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 91f5d4c7a7a..103a1bfe9c4 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index b79eccf5b48..c58f7b17443 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 79dd316cdd7..5075bd6d4ac 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 376af972e02..37af3a97df8 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -483,9 +483,6 @@ 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", "இயக்கு"), diff --git a/src/lang/template.rs b/src/lang/template.rs index f16cf1ebc59..e31425369fc 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -483,9 +483,6 @@ 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", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index bd87cf5a70c..8e2c33ebb7c 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -483,9 +483,6 @@ 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", "เปิดใช้งาน"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 2925ce79247..f548e39de40 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 438cb809143..6e22e4d7949 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -483,9 +483,6 @@ 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", "啟用"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 7e55426d185..f03bcb0892d 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -483,9 +483,6 @@ 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", "Увімкнути"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index af358831e76..0b9421ba487 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -483,9 +483,6 @@ 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"), diff --git a/src/lib.rs b/src/lib.rs index 49cb2b7e97c..20d5d6aabd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,10 +46,6 @@ 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/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/server/connection.rs b/src/server/connection.rs index bf05d56bd74..7dc41ecbb08 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -163,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, @@ -225,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)] @@ -1176,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(); @@ -1266,32 +1217,6 @@ 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")))] @@ -2032,13 +1957,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"))); @@ -3904,13 +3822,6 @@ impl Connection { self.change_resolution(Some(dr.display as _), &dr.resolution); } } - #[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::AutoAdjustFps(fps)) => video_service::VIDEO_QOS .lock() .unwrap() diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 9e4128dca79..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(); From 7aa98d43cf1962a7a29ec16ffef42974377ef11e Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:52:03 +0800 Subject: [PATCH 121/121] Refact/plugin removal leftovers (#15864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(flutter): dispose the settings PageController and order dispose() correctly `dispose()` began with `super.dispose()`, so the mixin chain marked the State defunct before the WidgetsBindingObserver registration and the periodic timer were released. The `PageController` was never disposed at all: `Get.delete` only runs `onDelete()` for a `GetLifeCycleBase`, and a plain `ChangeNotifier` is not one, so every open/close of the Settings tab leaked one controller with its listener still attached. Also guard `switch2page` on the `Rx` registration it actually reads rather than only the `PageController` — now that both are really deleted, a partial teardown would throw into the catch and silently open the wrong tab — and re-check `mounted` after the await in the `_videoConnTimer` tick, which `Timer::cancel` cannot stop once the body has started. Co-Authored-By: Claude Opus 5 (1M context) * refact: finish the plugin-framework removal sweep #15854 removed the feature but stopped short of its leftovers: - `Uninstall`, `Enable`, `Disable`, `Options` and `Please install plugins` were consumed only by the deleted `flutter/lib/plugin/**`; drop them from template.rs and the 50 locale files (250 dead entries). `Update` and `Install` stay, still used by desktop_home_page.dart. - The server no longer sends `PrvOnFailedPlugin`, and the client no longer offers to install plugins when privacy mode fails to turn on. - Drop the MSI `F_Client_Plugins` / `F_Server_Plugins` localization strings; no `.wxs` references them. - `_DisplayMenu`'s constructor became a pure pass-through once `pluginItem` was removed, and the cfg inside `handle_input` repeats the one on the function itself. - Normalize `src/lang/sl.rs` to 0644, the only executable file under src/. Co-Authored-By: Claude Opus 5 (1M context) * fix(client): handle legacy privacy mode plugin failures Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: fufesou --- .../lib/desktop/pages/desktop_setting_page.dart | 17 ++++++++++++----- flutter/lib/desktop/widgets/remote_toolbar.dart | 8 +++----- res/msi/Package/Language/Package.en-us.wxl | 4 ---- src/client/io_loop.rs | 8 ++------ src/lang/ar.rs | 5 ----- src/lang/be.rs | 5 ----- src/lang/bg.rs | 5 ----- src/lang/ca.rs | 5 ----- src/lang/cn.rs | 5 ----- src/lang/cs.rs | 5 ----- src/lang/da.rs | 5 ----- src/lang/de.rs | 5 ----- src/lang/el.rs | 5 ----- src/lang/eo.rs | 5 ----- src/lang/es.rs | 5 ----- src/lang/et.rs | 5 ----- src/lang/eu.rs | 5 ----- src/lang/fa.rs | 5 ----- src/lang/fi.rs | 5 ----- src/lang/fr.rs | 5 ----- src/lang/ge.rs | 5 ----- src/lang/gu.rs | 5 ----- src/lang/he.rs | 5 ----- src/lang/hi.rs | 5 ----- src/lang/hr.rs | 5 ----- src/lang/hu.rs | 5 ----- src/lang/id.rs | 5 ----- src/lang/it.rs | 5 ----- src/lang/ja.rs | 5 ----- src/lang/ko.rs | 5 ----- src/lang/kz.rs | 5 ----- src/lang/lt.rs | 5 ----- src/lang/lv.rs | 5 ----- src/lang/ml.rs | 5 ----- src/lang/nb.rs | 5 ----- src/lang/nl.rs | 5 ----- src/lang/pl.rs | 5 ----- src/lang/pt_PT.rs | 5 ----- src/lang/ptbr.rs | 5 ----- src/lang/ro.rs | 5 ----- src/lang/ru.rs | 5 ----- src/lang/sc.rs | 5 ----- src/lang/sk.rs | 5 ----- src/lang/sl.rs | 5 ----- src/lang/sq.rs | 5 ----- src/lang/sr.rs | 5 ----- src/lang/sv.rs | 5 ----- src/lang/ta.rs | 5 ----- src/lang/template.rs | 5 ----- src/lang/th.rs | 5 ----- src/lang/tr.rs | 5 ----- src/lang/tw.rs | 5 ----- src/lang/uk.rs | 5 ----- src/lang/vi.rs | 5 ----- src/server/connection.rs | 3 +-- 55 files changed, 18 insertions(+), 272 deletions(-) mode change 100755 => 100644 src/lang/sl.rs diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index a2eb94e420b..a67facfa974 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -90,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); @@ -158,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(); + _videoConnTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); Get.delete(tag: _kSettingPageControllerTag); Get.delete>(tag: _kSettingPageTabKeyTag); - WidgetsBinding.instance.removeObserver(this); - _videoConnTimer?.cancel(); + // Get.delete does not dispose a plain ChangeNotifier. + controller.dispose(); + super.dispose(); } List<_TabInfo> _settingTabs() { diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 0516608cdee..2627627a63e 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1476,13 +1476,11 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - _DisplayMenu( - {Key? key, - required this.id, + const _DisplayMenu( + {required this.id, required this.ffi, required this.state, - required this.setFullscreen}) - : super(key: key); + required this.setFullscreen}); @override State<_DisplayMenu> createState() => _DisplayMenuState(); 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/src/client/io_loop.rs b/src/client/io_loop.rs index bc1828fd872..33ee933570d 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -2260,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/lang/ar.rs b/src/lang/ar.rs index bc9ea67d8eb..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "البصمة"), ("Copy Fingerprint", "نسخ البصمة"), ("no fingerprints", "لا توجد بصمات اصابع"), - ("Uninstall", "الغاء التثبيت"), ("Update", "تحديث"), - ("Enable", "تفعيل"), - ("Disable", "تعطيل"), - ("Options", "الخيارات"), ("resolution_original_tip", "الدقة الأصلية"), ("resolution_fit_local_tip", "تناسب الدقة المحلية"), ("resolution_custom_tip", "دقة مخصصة"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 3dd0ef75ecf..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Адбітак"), ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), - ("Enable", "Уключыць"), - ("Disable", "Адключыць"), - ("Options", "Параметры"), ("resolution_original_tip", "Арыгінальная раздзяляльнасць"), ("resolution_fit_local_tip", "Супадзенне з лакальнай раздзяляльнасцю"), ("resolution_custom_tip", "Карыстацкая раздзяляльнасць"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 0d926db31b1..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Пръстов отпечатък"), ("Copy Fingerprint", "Копиране на пръстов отпечатък"), ("no fingerprints", "Няма пръстови отпечатъци"), - ("Uninstall", "Премахни"), ("Update", "Обновяване"), - ("Enable", "Позволяване"), - ("Disable", "Забрана"), - ("Options", "Настроики"), ("resolution_original_tip", "Оригинална разделителна способност"), ("resolution_fit_local_tip", "Приспособяване към тукашната разделителна способност"), ("resolution_custom_tip", "Разделителна способност по свой избор"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 17f5817b29e..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empremta"), ("Copy Fingerprint", "Copia l'empremta"), ("no fingerprints", "Cap empremta"), - ("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"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index d1ada573bf0..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指纹"), ("Copy Fingerprint", "复制指纹"), ("no fingerprints", "没有指纹"), - ("Uninstall", "卸载"), ("Update", "更新"), - ("Enable", "启用"), - ("Disable", "禁用"), - ("Options", "选项"), ("resolution_original_tip", "原始分辨率"), ("resolution_fit_local_tip", "适应本地分辨率"), ("resolution_custom_tip", "自定义分辨率"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 3bb59ecfba8..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisk"), ("Copy Fingerprint", "Kopírovat otisk"), ("no fingerprints", "žádný otisk"), - ("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í"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 823a58a6b90..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeraftryk"), ("Copy Fingerprint", "Kopiér fingeraftryk"), ("no fingerprints", "Ingen fingeraftryk"), - ("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"), diff --git a/src/lang/de.rs b/src/lang/de.rs index 7f30d1051e4..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingerabdruck"), ("Copy Fingerprint", "Fingerabdruck kopieren"), ("no fingerprints", "Keine Fingerabdrücke"), - ("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"), diff --git a/src/lang/el.rs b/src/lang/el.rs index e6c4ab6fe96..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", "Απενεργοποιημένο"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Δακτυλικό αποτύπωμα"), ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), - ("Uninstall", "Κατάργηση εγκατάστασης"), ("Update", "Ενημέρωση"), - ("Enable", "Ενεργοποίηση"), - ("Disable", "Απενεργοποίηση"), - ("Options", "Επιλογές"), ("resolution_original_tip", "Αρχική ανάλυση"), ("resolution_fit_local_tip", "Προσαρμογή στην τοπική ανάλυση"), ("resolution_custom_tip", "Προσαρμοσμένη ανάλυση"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 5e5c19d640c..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingrospuro"), ("Copy Fingerprint", "Kopii fingrospuron"), ("no fingerprints", "Neniuj fingrospuroj"), - ("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"), diff --git a/src/lang/es.rs b/src/lang/es.rs index 7f12ef41ed9..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,11 +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"), - ("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"), diff --git a/src/lang/et.rs b/src/lang/et.rs index d8cd510bb50..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,11 +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"), - ("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"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 838b263535a..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,11 +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"), - ("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"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 08dc9f568c2..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "\n اثر انگشت"), ("Copy Fingerprint", "کپی کردن اثر انگشت"), ("no fingerprints", "بدون اثر انگشت"), - ("Uninstall", "حذف نصب"), ("Update", "به روز رسانی"), - ("Enable", "فعال کردن"), - ("Disable", "غیر فعال کردن"), - ("Options", "گزینه ها"), ("resolution_original_tip", "وضوح اصلی"), ("resolution_fit_local_tip", "متناسب با وضوح محلی"), ("resolution_custom_tip", "وضوح سفارشی"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index d86cad0c724..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,11 +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ä"), - ("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"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index a20c0e41b5f..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,11 +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"), - ("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"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index ce6e89bb4bd..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ანაბეჭდი"), ("Copy Fingerprint", "ანაბეჭდის კოპირება"), ("no fingerprints", "ანაბეჭდები არ არის"), - ("Uninstall", "წაშლა"), ("Update", "განახლება"), - ("Enable", "ჩართვა"), - ("Disable", "გამორთვა"), - ("Options", "პარამეტრები"), ("resolution_original_tip", "საწყისი გარჩევადობა"), ("resolution_fit_local_tip", "ლოკალური გარჩევადობის შესაბამისი"), ("resolution_custom_tip", "მორგებული გარჩევადობა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 31e905ea7e9..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ફિંગરપ્રિન્ટ"), ("Copy Fingerprint", "ફિંગરપ્રિન્ટ કોપી કરો"), ("no fingerprints", "કોઈ ફિંગરપ્રિન્ટ નથી"), - ("Uninstall", "અનઇન્સ્ટોલ કરો"), ("Update", "અપડેટ કરો"), - ("Enable", "સક્ષમ કરો"), - ("Disable", "અક્ષમ કરો"), - ("Options", "વિકલ્પો"), ("resolution_original_tip", "મૂળ રિઝોલ્યુશન"), ("resolution_fit_local_tip", "સ્ક્રીન મુજબ ફીટ કરો"), ("resolution_custom_tip", "કસ્ટમ રિઝોલ્યુશન"), diff --git a/src/lang/he.rs b/src/lang/he.rs index b82f66dab0b..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Uninstall", "הסר"), ("Update", "עדכן"), - ("Enable", "פועל"), - ("Disable", "כבוי"), - ("Options", "אפשרויות"), ("resolution_original_tip", "רזולוציה מקורית"), ("resolution_fit_local_tip", "התאם לרזולוציה מקומית"), ("resolution_custom_tip", "רזולוציה מותאמת אישית"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 1e5d3a0b5f1..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "फिंगरप्रिंट"), ("Copy Fingerprint", "फिंगरप्रिंट कॉपी करें"), ("no fingerprints", "कोई फिंगरप्रिंट नहीं"), - ("Uninstall", "अनइंस्टॉल करें"), ("Update", "अपडेट करें"), - ("Enable", "सक्षम करें"), - ("Disable", "अक्षम करें"), - ("Options", "विकल्प"), ("resolution_original_tip", "मूल रिज़ॉल्यूशन"), ("resolution_fit_local_tip", "स्थानीय स्क्रीन में फिट करें"), ("resolution_custom_tip", "कस्टम रिज़ॉल्यूशन"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index a054622920e..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopirat otisak"), ("no fingerprints", "nema otiska"), - ("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"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 68d26c38904..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,11 +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"), - ("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"), diff --git a/src/lang/id.rs b/src/lang/id.rs index c51c908a432..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,11 +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"), - ("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"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 939048e3ba6..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,11 +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"), - ("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"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 2d944bf8fd5..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), - ("Uninstall", "アンインストール"), ("Update", "更新"), - ("Enable", "有効"), - ("Disable", "無効"), - ("Options", "設定"), ("resolution_original_tip", "オリジナルの解像度"), ("resolution_fit_local_tip", "ローカル解像度に合わせる"), ("resolution_custom_tip", "カスタム解像度"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index d46c327d3cf..a55eb6695d0 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Uninstall", "설치 제거"), ("Update", "업데이트"), - ("Enable", "허용"), - ("Disable", "사용 안 함"), - ("Options", "옵션"), ("resolution_original_tip", "원본 해상도"), ("resolution_fit_local_tip", "로컬 화면에 맞춤"), ("resolution_custom_tip", "사용자 지정 해상도"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index b73b8dac0dc..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Саусақ ізі"), ("Copy Fingerprint", "Саусақ ізін көшіру"), ("no fingerprints", "Саусақ іздері жоқ"), - ("Uninstall", "Жою"), ("Update", "Жаңарту"), - ("Enable", "Қосу"), - ("Disable", "Өшіру"), - ("Options", "Опциялар"), ("resolution_original_tip", "Түпнұсқа ажыратымдылық"), ("resolution_fit_local_tip", "Лақал ажыратымдылыққа сыйғызу"), ("resolution_custom_tip", "Теңшеулі ажыратымдылық"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 5c26e3119e5..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,11 +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ų"), - ("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"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 7ae3178938e..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,11 +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"), - ("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"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 69394909b85..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ഫിംഗർപ്രിന്റ്"), ("Copy Fingerprint", "ഫിംഗർപ്രിന്റ് കോപ്പി ചെയ്യുക"), ("no fingerprints", "ഫിംഗർപ്രിന്റുകൾ ഇല്ല"), - ("Uninstall", "അൺഇൻസ്റ്റാൾ ചെയ്യുക"), ("Update", "അപ്ഡേറ്റ് ചെയ്യുക"), - ("Enable", "പ്രവർത്തനക്ഷമമാക്കുക"), - ("Disable", "പ്രവർത്തനരഹിതമാക്കുക"), - ("Options", "ഓപ്ഷനുകൾ"), ("resolution_original_tip", "ഒറിജിനൽ റെസല്യൂഷൻ"), ("resolution_fit_local_tip", "ലോക്കൽ സ്ക്രീനിന് അനുയോജ്യം"), ("resolution_custom_tip", "കസ്റ്റം റെസല്യൂഷൻ"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index cf0009314b4..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtrykk"), ("Copy Fingerprint", "Kopier fingeravtrykk"), ("no fingerprints", "Ingen fingeravtrykk"), - ("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"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 68206f0d686..e94d66c94d0 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Iemand schakelt privacymodus in, afsluiten"), ("Unsupported", "Niet ondersteund"), ("Peer denied", "Peer geweigerd"), - ("Please install plugins", "Plugins installeren"), ("Peer exit", "Peer afgesloten"), ("Failed to turn off", "Uitschakelen mislukt"), ("Turned off", "Uitgeschakeld"), @@ -483,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Vingerafdruk"), ("Copy Fingerprint", "Vingerafdruk kopiëren"), ("no fingerprints", "geen vingerafdrukken"), - ("Uninstall", "Verwijderen"), ("Update", "Bijwerken"), - ("Enable", "Activeren"), - ("Disable", "Deactiveren"), - ("Options", "Opties"), ("resolution_original_tip", "Oorspronkelijke resolutie"), ("resolution_fit_local_tip", "Lokale resolutie aanpassen"), ("resolution_custom_tip", "Aangepaste resolutie"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 0e2e03f0223..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sygnatura"), ("Copy Fingerprint", "Skopiuj sygnaturę"), ("no fingerprints", "brak sygnatur"), - ("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"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index a391fcfdc6d..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,11 +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"), - ("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"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 522b3f8b824..69adca61ec0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -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,11 +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"), - ("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"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index cc774057ef4..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,11 +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ă"), - ("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ă"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 2ded4ebf575..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Отпечаток"), ("Copy Fingerprint", "Копировать отпечаток"), ("no fingerprints", "отпечатки отсутствуют"), - ("Uninstall", "Удалить"), ("Update", "Обновить"), - ("Enable", "Включить"), - ("Disable", "Отключить"), - ("Options", "Настройки"), ("resolution_original_tip", "Исходное разрешение"), ("resolution_fit_local_tip", "Соответствие локальному разрешению"), ("resolution_custom_tip", "Произвольное разрешение"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 6bb1190c1d2..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,11 +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"), - ("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"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 96eb423efc8..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,11 +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"), - ("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"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs old mode 100755 new mode 100644 index 82f17742811..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,11 +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"), - ("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"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 103a1bfe9c4..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,11 +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"), - ("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"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index c58f7b17443..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopiraj otisak"), ("no fingerprints", "Nema otisaka"), - ("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"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 5075bd6d4ac..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtryck"), ("Copy Fingerprint", "Kopiera fingeravtryck"), ("no fingerprints", "inga fingeravtryck"), - ("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"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 37af3a97df8..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "கைரேகை"), ("Copy Fingerprint", "கைரேகை நகல்"), ("no fingerprints", "கைரேகைகள் இல்லை"), - ("Uninstall", "நிறுவல் நீக்கு"), ("Update", "புதுப்பி"), - ("Enable", "இயக்கு"), - ("Disable", "அணை"), - ("Options", "விருப்பங்கள்"), ("resolution_original_tip", "அசல் தெளிவுத்திறன்"), ("resolution_fit_local_tip", "உள்ளூர் பொருத்தம்"), ("resolution_custom_tip", "தனிப்பயன் தெளிவுத்திறன்"), diff --git a/src/lang/template.rs b/src/lang/template.rs index e31425369fc..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", ""), ("Copy Fingerprint", ""), ("no fingerprints", ""), - ("Uninstall", ""), ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), ("resolution_original_tip", ""), ("resolution_fit_local_tip", ""), ("resolution_custom_tip", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index 8e2c33ebb7c..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ลายนิ้วมือ"), ("Copy Fingerprint", "คัดลอกลายนิ้วมือ"), ("no fingerprints", "ไม่มีลายนิ้วมือ"), - ("Uninstall", "ถอนการติดตั้ง"), ("Update", "อัปเดต"), - ("Enable", "เปิดใช้งาน"), - ("Disable", "ปิดใช้งาน"), - ("Options", "ตัวเลือก"), ("resolution_original_tip", "ความละเอียดดั้งเดิม"), ("resolution_fit_local_tip", "ความละเอียดตามต้นทาง"), ("resolution_custom_tip", "ความละเอียดแบบกำหนดเอง"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index f548e39de40..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,11 +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"), - ("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"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 6e22e4d7949..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指紋"), ("Copy Fingerprint", "複製指紋"), ("no fingerprints", "沒有指紋"), - ("Uninstall", "解除安裝"), ("Update", "更新"), - ("Enable", "啟用"), - ("Disable", "停用"), - ("Options", "選項"), ("resolution_original_tip", "原始解析度"), ("resolution_fit_local_tip", "調整成本機解析度"), ("resolution_custom_tip", "自訂解析度"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index f03bcb0892d..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,11 +482,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Відбитки пальців"), ("Copy Fingerprint", "Копіювати відбитки пальців"), ("no fingerprints", "немає відбитків пальців"), - ("Uninstall", "Видалити"), ("Update", "Оновити"), - ("Enable", "Увімкнути"), - ("Disable", "Вимкнути"), - ("Options", "Опції"), ("resolution_original_tip", "Початкова роздільна здатність"), ("resolution_fit_local_tip", "Припасувати поточну роздільну здатність"), ("resolution_custom_tip", "Користувацька роздільна здатність"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 0b9421ba487..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,11 +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"), - ("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"), diff --git a/src/server/connection.rs b/src/server/connection.rs index 7dc41ecbb08..fb7d1a2fe50 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1219,7 +1219,6 @@ impl Connection { } }, Err(err) => { - #[cfg(not(any(target_os = "android", target_os = "ios")))] if block_input_mode { let _ = crate::platform::block_input(true); } @@ -5011,7 +5010,7 @@ impl Connection { } } else { crate::common::make_privacy_mode_msg( - back_notification::PrivacyModeState::PrvOnFailedPlugin, + back_notification::PrivacyModeState::PrvOnFailed, impl_key, ) }