diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 56258e4e0..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "gitsubmodule" - directory: "/" - target-branch: "master" - schedule: - interval: "daily" - commit-message: - prefix: "Git submodule" - labels: - - "dependencies" diff --git a/.github/patches/apply_flutter_3.44_source_patches.sh b/.github/patches/apply_flutter_3.44_source_patches.sh index 3a7ab99dc..2b4bfcc0d 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 000000000..24ce7f1b4 --- /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 a7b74fa55..9d31399c8 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/ci.yml b/.github/workflows/ci.yml index 173eda9f4..ecc9ee782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ env: # CICD_INTERMEDIATES_DIR: "_cicd-intermediates" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" # for multiarch gcc compatibility - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" on: workflow_dispatch: @@ -124,7 +124,6 @@ jobs: gcc \ git \ g++ \ - libpam0g-dev \ libasound2-dev \ libunwind-dev \ libgstreamer1.0-dev \ diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 464b1ebdf..bf1b7610c 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -31,20 +31,20 @@ 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.9" # for arm64 linux because official Dart SDK does not work FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "${{ inputs.upload-tag }}" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - # vcpkg version: 2025.08.27 + # vcpkg version: 2026.07.29 # 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 # 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" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version - VERSION: "1.4.9" + VERSION: "1.5.0" NDK_VERSION: "r28c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" @@ -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 @@ -643,7 +645,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 @@ -822,7 +826,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 @@ -1069,7 +1075,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1099,7 +1104,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 @@ -1341,7 +1348,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1371,7 +1377,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 @@ -1630,7 +1638,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1815,6 +1822,275 @@ 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 \ + 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 }} @@ -1912,7 +2188,6 @@ jobs: libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ liblzma-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libxcb-randr0-dev \ @@ -1989,7 +2264,7 @@ jobs: mkdir -p ~/.cargo/ echo """ [source.crates-io] - registry = 'https://github.com/rust-lang/crates.io-index' + registry = 'sparse+https://index.crates.io/' """ > ~/.cargo/config cat ~/.cargo/config # install dependencies from vcpkg @@ -2209,7 +2484,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/.github/workflows/playground.yml b/.github/workflows/playground.yml index 41b9c0c13..7478bee9d 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -16,8 +16,8 @@ env: FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - VERSION: "1.4.9" + VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d" + VERSION: "1.5.0" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" @@ -271,7 +271,6 @@ jobs: libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ - libpam0g-dev \ libpulse-dev \ libva-dev \ libvdpau-dev \ @@ -284,7 +283,7 @@ jobs: nasm \ yasm \ ninja-build \ - openjdk-11-jdk-headless \ + openjdk-17-jdk-headless \ pkg-config \ tree \ wget @@ -366,9 +365,9 @@ jobs: - name: Build rustdesk shell: bash env: - JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64 + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 run: | - export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH + export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH # temporary use debug sign config sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle case ${{ matrix.job.target }} in diff --git a/.github/workflows/update-webpki-roots.yml b/.github/workflows/update-webpki-roots.yml index e1efdb0d6..bf3150653 100644 --- a/.github/workflows/update-webpki-roots.yml +++ b/.github/workflows/update-webpki-roots.yml @@ -33,6 +33,10 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # The root workspace lists libs/hbb_common as a member; without the + # submodule its manifest is missing and cargo cannot load the workspace. + submodules: recursive - name: Update webpki-roots in all lockfiles id: update diff --git a/.gitignore b/.gitignore index d2e09a906..f51a5b8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# libdrmtap is cloned at build time by build.py (not a submodule) +/third_party/libdrmtap/ diff --git a/AGENTS.md b/AGENTS.md index 8f558c959..fe8b73ec7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,12 +61,26 @@ * Do not make formatting-only changes. * Keep naming/style consistent with nearby code. +### Comments + +* Avoid comments unless they explain a non-obvious reason, constraint, or workaround. +* Never restate what the code does; prefer clearer code instead. +* If the code is self-explanatory, add no comment. + ### 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). +* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it. * 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. +## Reviewing a PR + +* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's. +* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix. +* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds. +* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves". + ## Localization (`src/lang/*.rs`) Each file is a `HashMap`. Layout: diff --git a/CLAUDE.md b/CLAUDE.md index c31706425..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 93e1a6837..1746adc00 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" @@ -3074,9 +3047,8 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +version = "0.16.0" +source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877" dependencies = [ "libc", "log", @@ -3819,14 +3791,14 @@ dependencies = [ "toml 0.7.8", "tungstenite", "url", - "users 0.11.0", + "users", "uuid", "webpki-roots 1.0.9", "webrtc", "whoami", "winapi 0.3.9", "x11 2.21.0", - "zstd 0.13.1", + "zstd", ] [[package]] @@ -5960,37 +5932,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "pam" -version = "0.7.0" -source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762" -dependencies = [ - "libc", - "pam-macros", - "pam-sys", - "users 0.10.0", -] - -[[package]] -name = "pam-macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" -dependencies = [ - "proc-macro2 1.0.93", - "quote 1.0.36", - "syn 1.0.109", -] - -[[package]] -name = "pam-sys" -version = "1.0.0-alpha4" -source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7" -dependencies = [ - "bindgen 0.59.2", - "libc", -] - [[package]] name = "pango" version = "0.18.3" @@ -6058,35 +5999,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" @@ -7259,7 +7177,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" dependencies = [ "android-wakelock", "android_logger", @@ -7317,7 +7235,6 @@ dependencies = [ "once_cell", "openssl", "os-version", - "pam", "parity-tokio-ipc", "percent-encoding", "piet", @@ -7366,12 +7283,11 @@ dependencies = [ "wol-rs", "x11-clipboard 0.8.1", "x11rb 0.12.0", - "zip", ] [[package]] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" dependencies = [ "brotli", "dirs 5.0.1", @@ -8908,7 +8824,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", @@ -9353,16 +9269,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "users" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" -dependencies = [ - "libc", - "log", -] - [[package]] name = "users" version = "0.11.0" @@ -11158,52 +11064,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 a7b2aca77..b1d9b7f91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.9" +version = "1.5.0" authors = ["rustdesk "] edition = "2021" build= "build.rs" @@ -30,7 +30,13 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] -plugin_framework = [] +drm = ["scrap/drm"] +# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend +# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the +# root service so a compositor that idle-disabled its outputs re-enables them. That is a different +# kind of operation and deserves a switch that can remove it from the binary entirely, without +# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in. +drm-wake = ["drm"] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ "dep:x11-clipboard", @@ -74,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" @@ -184,7 +189,6 @@ async-process = "1.7" evdev = { git="https://github.com/rustdesk-org/evdev" } dbus = "0.9" dbus-crossroads = "0.5" -pam = { git="https://github.com/rustdesk-org/pam" } x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} percent-encoding = {version = "2.3", optional = true} @@ -205,7 +209,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/Dockerfile b/Dockerfile index f0e4e4a4a..e6c95ad52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN apt update -y && \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ libasound2-dev \ - libpam0g-dev \ libpulse-dev \ make \ wget \ diff --git a/README.md b/README.md index ae5c8d37c..a593a191f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ BuildDockerStructure • - Snapshot
+ Screenshots
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe] | [Norsk] | [Română]
We need your help to translate this README, RustDesk UI and RustDesk Doc to your native language

@@ -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. @@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -168,7 +168,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client ## Screenshots diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index bad4e84db..2a0061bef 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.9 + version: 1.5.0 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: @@ -58,7 +58,6 @@ AppDir: - libpulse0 - packagekit-gtk3-module - libcanberra-gtk3-module - - libpam0g - libdrm2 exclude: - humanity-icon-theme @@ -77,6 +76,13 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules GDK_BACKEND: x11 + # AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG + # defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+ + # (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first + # remote cursor. The host value goes last: unset it expands to an empty element, which GLib + # resolves against the CWD, and that must not outrank the defaults below. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS + XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0 diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index 7cd52b89a..49ede99eb 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.9 + version: 1.5.0 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: @@ -61,7 +61,6 @@ AppDir: - libpulse0 - packagekit-gtk3-module - libcanberra-gtk3-module - - libpam0g - libdrm2 exclude: - humanity-icon-theme @@ -80,6 +79,13 @@ AppDir: env: GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules GDK_BACKEND: x11 + # AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG + # defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+ + # (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first + # remote cursor. The host value goes last: unset it expands to an empty element, which GLib + # resolves against the CWD, and that must not outrank the defaults below. + XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS + XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64 GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0 diff --git a/build.py b/build.py index 957961857..4f1953662 100755 --- a/build.py +++ b/build.py @@ -1,16 +1,25 @@ #!/usr/bin/env python3 import os +import glob +import contextlib import pathlib import platform import zipfile import urllib.request import shutil import hashlib +import re +import subprocess import argparse import sys from pathlib import Path +# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__ +# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04 +# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code. +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) + windows = platform.platform().startswith('Windows') osx = platform.platform().startswith( 'Darwin') or platform.platform().startswith("macOS") @@ -130,6 +139,19 @@ def make_parser(): action='store_true', help='Build with unix file copy paste feature' ) + parser.add_argument( + '--drm', + action='store_true', + help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, ' + 'dlopen-ed in-process by the root service). Off by default.' + ) + parser.add_argument( + '--print-features', + action='store_true', + help='Print the cargo feature list these flags select, and exit without building. For a ' + 'caller that runs its own cargo line and then packages with --skip-cargo: it can ask ' + 'for the list rather than repeat it, so the two cannot drift.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -272,6 +294,24 @@ def external_resources(flutter, args, res_dir): shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}') +def linux_packaging_branch(): + """Which packaging path `main()` will take on THIS host. + + MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can + refuse a branch that is not drm-aware instead of silently producing a stock-named package with + the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which + is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged + binary really is a drm build. + """ + if os.path.isfile('/usr/bin/pacman'): + return 'pacman' + if os.path.isfile('/usr/bin/yum'): + return 'yum' + if os.path.isfile('/usr/bin/zypper'): + return 'zypper' + return 'deb' + + def get_features(args): features = ['inline'] if not args.flutter else [] if args.hwcodec: @@ -282,6 +322,30 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if args.drm: + # Say so rather than quietly handing back a stock build: the backend is Linux-only, so on + # any other host the flag cannot be honoured and the resulting binary would look like a + # DRM build without being one. + if windows or osx: + raise Exception('--drm is Linux only') + # And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package + # straight from `target/release` without bundling libdrmtap, without the rename, without + # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a + # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput + # injection. The separate package name is the informed consent this feature rests on, so + # refuse rather than ship a stock-named build of it. + branch = linux_packaging_branch() + if branch != 'deb': + raise Exception( + f'--drm is only supported on the deb packaging path; this host would package via ' + f'{branch}, which cannot bundle libdrmtap or name the package distinctly') + features.append('drm') + # The display wake is its own compile gate on top of `drm`, and the unattended package is + # exactly where it belongs: that variant exists to reach a machine nobody is sitting at, + # and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from + # this line builds the same capture backend with no wake code in the binary at all. + # It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE. + features.append('drm-wake') if osx: if args.screencapturekit: features.append('screencapturekit') @@ -300,7 +364,7 @@ Version: %s Architecture: %s Maintainer: rustdesk Homepage: https://rustdesk.com -Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s +Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s Recommends: libayatana-appindicator3-1 Description: A remote control software. @@ -316,6 +380,322 @@ 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 _max_glibc_minor(path): + # Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because + # librustdesk.so is ~45 MB. + best = 0 + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return best + for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk): + best = max(best, int(m.group(1))) + tail = chunk[-16:] + + +def measured_glibc_floor(): + # libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged + # object is higher -- and it moves whenever either base does. + paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*') + + glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') + if os.path.isfile(p) and not os.path.islink(p)] + minor = max((_max_glibc_minor(p) for p in paths), default=0) + if not minor: + raise Exception( + f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); ' + 'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which ' + 'is what lets it install on a host where libdrmtap can never load') + return f'2.{minor}' + + +def retarget_control_to_drm_variant(): + # Rewrite the control file that generate_control_file just produced, instead of parameterizing that + # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to + # this variant lives here. The variant installs the same files as the stock package, so it must + # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's + # own runtime deps, which the stock package has no reason to carry. + path = '../res/DEBIAN/control' + floor = measured_glibc_floor() + print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}') + with open(path) as f: + lines = f.readlines() + out = [] + for line in lines: + if line.startswith('Package: rustdesk'): + out.append(f'Package: {DRM_PACKAGE_NAME}\n') + out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') + elif line.startswith('Depends:'): + # 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture. + out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, ' + f'libc6 (>= {floor})\n') + else: + out.append(line) + body = ''.join(out) + # Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file + # that stopped matching either anchor would otherwise produce a variant deb wearing the stock name. + if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body: + raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed') + with open(path, 'w') as f: + f.write(body) + + def build_flutter_deb(version, features): if not skip_cargo: system2(f'cargo build --locked --features {features} --lib --release') @@ -324,8 +704,6 @@ def build_flutter_deb(version, features): system2('flutter build linux --release') system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/share/rustdesk') - system2('mkdir -p tmpdeb/etc/rustdesk/') - system2('mkdir -p tmpdeb/etc/pam.d/') system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/') system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/') @@ -344,17 +722,24 @@ def build_flutter_deb(version, features): 'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') system2( 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') - system2( - 'cp ../res/startwm.sh tmpdeb/etc/rustdesk/') - system2( - 'cp ../res/xorg.conf tmpdeb/etc/rustdesk/') - system2( - '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 +747,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 +832,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 +886,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 +962,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 +990,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) @@ -622,13 +1124,7 @@ def main(): 'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop') system2( 'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') - os.system('mkdir -p tmpdeb/etc/rustdesk/') - os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/') - os.system('mkdir -p tmpdeb/etc/X11/rustdesk/') - os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/') os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/') - os.system('mkdir -p tmpdeb/etc/pam.d/') - os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2('strip tmpdeb/usr/bin/rustdesk') system2('mkdir -p tmpdeb/usr/share/rustdesk') system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/') diff --git a/build.rs b/build.rs index 92fb1f4b4..ec87831c0 100644 --- a/build.rs +++ b/build.rs @@ -72,7 +72,6 @@ fn install_android_deps() { path.join("lib").to_str().unwrap() ); println!("cargo:rustc-link-lib=ndk_compat"); - println!("cargo:rustc-link-lib=oboe"); println!("cargo:rustc-link-lib=c++"); println!("cargo:rustc-link-lib=OpenSLES"); } diff --git a/docs/CONTRIBUTING-ID.md b/docs/CONTRIBUTING-ID.md index cdff6c01f..b5ab29f46 100644 --- a/docs/CONTRIBUTING-ID.md +++ b/docs/CONTRIBUTING-ID.md @@ -24,7 +24,7 @@ Untuk instruksi Git yang lebih lanjut, cek disini [GitHub workflow 101](https:// ## Tindakan - + ## Komunikasi diff --git a/docs/CONTRIBUTING-IT.md b/docs/CONTRIBUTING-IT.md index a3a5fd2b6..f3ea9fbb7 100644 --- a/docs/CONTRIBUTING-IT.md +++ b/docs/CONTRIBUTING-IT.md @@ -30,7 +30,7 @@ Per istruzioni specifiche su git, vedi [Workflow GitHub - 101](https://github.co ## Condotta -https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md +https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md ## Comunicazioni diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 31fd632e6..43bbf27a6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to RustDesk -RustDesk welcomes contribution from everyone. Here are the guidelines if you are +RustDesk welcomes contributions from everyone. Here are the guidelines if you are thinking of helping us: ## Contributions diff --git a/docs/README-AR.md b/docs/README-AR.md index 5aa09da88..6996ff7c3 100644 --- a/docs/README-AR.md +++ b/docs/README-AR.md @@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript ## لقطات diff --git a/docs/README-CS.md b/docs/README-CS.md index b208414fe..2555bd8dd 100644 --- a/docs/README-CS.md +++ b/docs/README-CS.md @@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk, - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient ## Ukázky diff --git a/docs/README-DE.md b/docs/README-DE.md index ba8894411..91ba5a08c 100644 --- a/docs/README-DE.md +++ b/docs/README-DE.md @@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient ## Screenshots diff --git a/docs/README-ES.md b/docs/README-ES.md index da939bd7b..bdf099ffd 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed") - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter > [!Precaución] > **Descargo de responsabilidad por uso indebido:**
diff --git a/docs/README-FA.md b/docs/README-FA.md index a0645e02b..a0bef5acb 100644 --- a/docs/README-FA.md +++ b/docs/README-FA.md @@ -146,7 +146,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client ## تصاویر محیط نرم‌افزار diff --git a/docs/README-GR.md b/docs/README-GR.md index 8b0276bf8..1346bedbb 100644 --- a/docs/README-GR.md +++ b/docs/README-GR.md @@ -158,7 +158,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client ## Στιγμιότυπα diff --git a/docs/README-HU.md b/docs/README-HU.md index 82d1d5550..fc74d4bfe 100644 --- a/docs/README-HU.md +++ b/docs/README-HU.md @@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről - Futtasd a `cargo run` parancsot -## [Építés](https://rustdesk.com/docs/hu/dev/build/) +## [Építés](https://rustdesk.com/docs/en/dev/build/) ## Hogyan építs Linuxon @@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client ## Képernyőképek diff --git a/docs/README-IT.md b/docs/README-IT.md index 0393ee6c7..ee5351b6c 100644 --- a/docs/README-IT.md +++ b/docs/README-IT.md @@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed) - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter > [!Attenzione] > **Dichiarazione di non responsabilità per uso improprio:**
diff --git a/docs/README-JP.md b/docs/README-JP.md index c9f75640b..6abeae5c0 100644 --- a/docs/README-JP.md +++ b/docs/README-JP.md @@ -166,7 +166,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript > [!注意] > **:不正使用に関する免責事項**
diff --git a/docs/README-KR.md b/docs/README-KR.md index d7d3cf43e..687ba24e6 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는 - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기 - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드 -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript ## 스크린샷 diff --git a/docs/README-NO.md b/docs/README-NO.md index 1352e8aed..609795996 100644 --- a/docs/README-NO.md +++ b/docs/README-NO.md @@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient ## Skjermbilder diff --git a/docs/README-PL.md b/docs/README-PL.md index 437682a9c..4b48b0996 100644 --- a/docs/README-PL.md +++ b/docs/README-PL.md @@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web ## Zrzuty ekranu diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index 2b4c1e6c2..332967ea2 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria ### Ubuntu 18 (Debian 10) ```sh -sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev +sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma. - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis. -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter. ## Capturas de Tela diff --git a/docs/README-RO.md b/docs/README-RO.md index be7ecf164..0f2f17466 100644 --- a/docs/README-RO.md +++ b/docs/README-RO.md @@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter. ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web ## Capturi de ecran diff --git a/docs/README-RU.md b/docs/README-RU.md index 928faad07..e3e97d8ca 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт - Выполните команду `cargo run` -## [Сборка](https://rustdesk.com/docs/ru/dev/build/) +## [Сборка](https://rustdesk.com/docs/en/dev/build/) ## Как собрать на Linux @@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -170,7 +170,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter ## Скриншоты @@ -180,4 +179,4 @@ target/release/rustdesk ![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) \ No newline at end of file +![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-TR.md b/docs/README-TR.md index 99c961e8b..022335b94 100644 --- a/docs/README-TR.md +++ b/docs/README-TR.md @@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript ## Ekran Görüntüleri diff --git a/docs/README-UA.md b/docs/README-UA.md index eb4c9edec..12d98dbdf 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT ```sh sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \ libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \ - libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev ``` ### openSUSE Tumbleweed ```sh -sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel +sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel ``` ### Fedora 28 (CentOS 8) ```sh -sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel +sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel ``` ### Arch (Manjaro) @@ -160,7 +160,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter ## Знімки екрана diff --git a/docs/README-VN.md b/docs/README-VN.md index 38cdc10fb..34cef261f 100644 --- a/docs/README-VN.md +++ b/docs/README-VN.md @@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter ## Snapshot diff --git a/docs/README-ZH.md b/docs/README-ZH.md index 9328e52e9..dc73d85a5 100644 --- a/docs/README-ZH.md +++ b/docs/README-ZH.md @@ -220,7 +220,6 @@ target/release/rustdesk - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继) - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码 -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码 ## 截图 diff --git a/flatpak/rustdesk.json b/flatpak/rustdesk.json index 2418ac2a6..108a4ba1d 100644 --- a/flatpak/rustdesk.json +++ b/flatpak/rustdesk.json @@ -21,18 +21,6 @@ } ] }, - { - "name": "pam", - "buildsystem": "autotools", - "config-opts": ["--disable-selinux"], - "sources": [ - { - "type": "archive", - "url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz", - "sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db" - } - ] - }, { "name": "rustdesk", "buildsystem": "simple", @@ -63,4 +51,4 @@ "--socket=pulseaudio", "--talk-name=org.freedesktop.Flatpak" ] -} \ No newline at end of file +} diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 830cbc2dd..44eb32ca0 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -82,7 +82,8 @@ protobuf { } android { - compileSdkVersion 34 + namespace "com.carriez.flutter_hbb" + compileSdkVersion 36 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -91,6 +92,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true targetCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8 } @@ -99,7 +101,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 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -128,6 +130,7 @@ flutter { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' implementation 'com.google.protobuf:protobuf-javalite:3.20.1' implementation "androidx.media:media:1.6.0" implementation 'com.github.getActivity:XXPermissions:18.5' diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4c..e07881846 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,19 @@ - + + + - - + + + @@ -26,7 +30,6 @@ android:name=".MainApplication" android:icon="@mipmap/ic_launcher" android:label="RustDesk" - android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true"> @@ -88,7 +91,12 @@ + android:exported="false" + android:foregroundServiceType="specialUse|mediaProjection|microphone"> + + Boolean, private var isAudioStart: ()->Boolean) { - private val logTag = "LOG_AUDIO_RECORD_HANDLE" + companion object { + private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE" + private const val NO_ACTIVE_PUBLISHERS = 0 + private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS + + @Synchronized + private fun acquireAudioFramePublisher() { + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + FFI.setFrameRawEnable("audio", true) + } + activeAudioFramePublishers++ + } + + @Synchronized + private fun releaseAudioFramePublisher() { + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + Log.e(LOG_TAG, "No active audio frame publisher to release") + return + } + activeAudioFramePublishers-- + if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) { + FFI.setFrameRawEnable("audio", false) + } + } + } + + private val logTag = LOG_TAG private var audioRecorder: AudioRecord? = null private var audioReader: AudioReader? = null @@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: return } // read f32 to byte , length * 4 - minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize( + val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize( AUDIO_SAMPLE_RATE, AUDIO_CHANNEL_MASK, AUDIO_ENCODING ) - if (minBufferSize == 0) { + if (bufferSize <= 0) { Log.d(logTag, "get min buffer size fail!") return } - audioReader = AudioReader(minBufferSize, 4) + audioReader = AudioReader(bufferSize, 4) + minBufferSize = bufferSize Log.d(logTag, "init audioData len:$minBufferSize") } - @RequiresApi(Build.VERSION_CODES.M) - fun startAudioRecorder() { - checkAudioReader() - if (audioReader != null && audioRecorder != null && minBufferSize != 0) { - try { - FFI.setFrameRawEnable("audio", true) - audioRecorder!!.startRecording() - audioRecordStat = true - audioThread = thread { - while (audioRecordStat) { - audioReader!!.readSync(audioRecorder!!)?.let { - FFI.onAudioFrameUpdate(it) - } - } - // let's release here rather than onDestroy to avoid threading issue - audioRecorder?.release() - audioRecorder = null - minBufferSize = 0 - FFI.setFrameRawEnable("audio", false) - Log.d(logTag, "Exit audio thread") - } - } catch (e: Exception) { - Log.d(logTag, "startAudioRecorder fail:$e") + private fun releaseRecorder(recorder: AudioRecord) { + try { + recorder.release() + } finally { + if (audioRecorder === recorder) { + audioRecorder = null } - } else { - Log.d(logTag, "startAudioRecorder fail") } } + private fun captureAudio(reader: AudioReader, recorder: AudioRecord) { + try { + while (audioRecordStat) { + reader.readSync(recorder)?.let { + FFI.onAudioFrameUpdate(it) + } + } + } finally { + minBufferSize = 0 + try { + releaseRecorder(recorder) + } finally { + releaseAudioFramePublisher() + Log.d(logTag, "Exit audio thread") + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + fun startAudioRecorder(): Boolean { + val recorder = audioRecorder + if (recorder == null) { + Log.d(logTag, "startAudioRecorder fail") + return false + } + var audioFramePublisherAcquired = false + return try { + checkAudioReader() + val reader = audioReader + if (reader == null || minBufferSize == 0) { + releaseRecorder(recorder) + Log.d(logTag, "startAudioRecorder fail") + return false + } + recorder.startRecording() + if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + throw IllegalStateException("AudioRecord failed to enter recording state") + } + audioRecordStat = true + val captureThread = thread(start = false) { captureAudio(reader, recorder) } + acquireAudioFramePublisher() + audioFramePublisherAcquired = true + audioThread = captureThread + captureThread.start() + true + } catch (error: Exception) { + audioRecordStat = false + audioThread = null + Log.e(logTag, "startAudioRecorder fail", error) + try { + releaseRecorder(recorder) + } finally { + if (audioFramePublisherAcquired) { + releaseAudioFramePublisher() + } + } + false + } + } + + fun isVoiceCallActive(): Boolean { + return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION + } + fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean { if (!isSupportVoiceCall()) { return false @@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: if (!isSupportVoiceCall()) { return true } - if (isVideoStart()) { - switchOutVoiceCall(mediaProjection) - } + val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection) tryReleaseAudio() - return true + return switched } @RequiresApi(Build.VERSION_CODES.M) @@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: Log.e(logTag, "createAudioRecorder fail") return false } - startAudioRecorder() - return true + return startAudioRecorder() } @RequiresApi(Build.VERSION_CODES.M) @@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart: Log.e(logTag, "createAudioRecorder fail") return false } - startAudioRecorder() - return true + return startAudioRecorder() } fun tryReleaseAudio() { 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 7274085fd..02cec3c25 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 @@ -9,6 +9,7 @@ package com.carriez.flutter_hbb import ffi.FFI +import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent @@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar import android.media.MediaCodecList import android.media.MediaFormat +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap import android.util.DisplayMetrics import androidx.annotation.RequiresApi import org.json.JSONArray @@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import kotlin.concurrent.thread +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream class MainActivity : FlutterActivity() { @@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() { private val channelTag = "mChannel" private val logTag = "mMainActivity" private var mainService: MainService? = null + private sealed class PendingPicker { + data class ImportFiles(val result: MethodChannel.Result) : PendingPicker() + data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker() + data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker() + data class ExportFiles( + val sources: List, + val rejected: Int, + val result: MethodChannel.Result + ) : PendingPicker() + } + + private data class ExportSource( + val file: File, + val children: List? + ) + + private var pendingPicker: PendingPicker? = null private var isAudioStart = false private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart }) @@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQ_IMPORT_FILES) { + val pending = pendingPicker as? PendingPicker.ImportFiles ?: return + pendingPicker = null + if (resultCode != Activity.RESULT_OK || data == null) { + pending.result.success(emptyList>()) + return + } + + val uris = linkedSetOf() + data.data?.let { uris.add(it) } + data.clipData?.let { clipData -> + for (index in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(index).uri) + } + } + thread { + val files = uris.map { uri -> + mapOf( + "uri" to uri.toString(), + "name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty()) + ) + } + runOnUiThread { pending.result.success(files) } + } + return + } + if (requestCode == REQ_EXPORT_FILE) { + val pending = pendingPicker as? PendingPicker.ExportFile ?: return + pendingPicker = null + val destination = data?.data + + if (resultCode != Activity.RESULT_OK || destination == null) { + pending.result.success(false) + return + } + + thread { + try { + FileInputStream(pending.source).use { input -> + contentResolver.openOutputStream(destination, "wt")?.use { output -> + input.copyTo(output) + } ?: throw IllegalStateException("Unable to open the selected destination") + } + runOnUiThread { pending.result.success(true) } + } catch (e: Exception) { + Log.e(logTag, "Failed to export file", e) + runOnUiThread { + pending.result.error("export_failed", e.message, null) + } + } + } + return + } + if (requestCode == REQ_IMPORT_DIRECTORY) { + val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return + pendingPicker = null + val treeUri = data?.data + if (resultCode != Activity.RESULT_OK || treeUri == null) { + pending.result.success(null) + return + } + thread { + val selected = mapOf( + "uri" to treeUri.toString(), + "name" to (treeDisplayName(treeUri) ?: "Imported") + ) + runOnUiThread { pending.result.success(selected) } + } + return + } + if (requestCode == REQ_EXPORT_FILES) { + val pending = pendingPicker as? PendingPicker.ExportFiles ?: return + pendingPicker = null + val treeUri = data?.data + if (resultCode != Activity.RESULT_OK || treeUri == null) { + pending.result.success(null) + return + } + thread { + var exported = 0 + var failed = pending.rejected + var processed = 0 + try { + val sources = pending.sources.map { snapshotExportSource(it) } + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + sources.forEach { source -> + val ok = source?.let { + copyExportSourceToTree(treeUri, rootDocId, it) + } ?: false + if (ok) exported++ else failed++ + processed++ + } + } catch (e: Exception) { + Log.e(logTag, "Failed to export selected files", e) + failed += pending.sources.size - processed + } + runOnUiThread { + pending.result.success(mapOf("exported" to exported, "failed" to failed)) + } + } + return + } if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) { flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null) } @@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() { result.success(false) } } + PICK_IMPORT_FILES -> { + if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ImportFiles(result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + }, + REQ_IMPORT_FILES + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + IMPORT_FILE -> { + val arguments = call.arguments as? Map<*, *> + val uri = (arguments?.get("uri") as? String)?.let { + runCatching { Uri.parse(it) }.getOrNull() + } + val path = arguments?.get("path") as? String + val overwrite = arguments?.get("overwrite") as? Boolean ?: false + val destination = path?.let { canonicalAppScopedFile(it) } + + if (uri?.scheme != "content") { + result.error("invalid_uri", "The selected document URI is invalid", null) + } else if (destination == null || + destination.isDirectory || + destination.parentFile?.isDirectory != true) { + result.error("invalid_destination", "The destination is outside app-scoped storage", null) + } else { + thread { + var temporary: File? = null + var reservedDestination = false + var errorCode = "import_failed" + try { + val temporaryFile = File.createTempFile( + ".rustdesk-import-", + ".tmp", + destination.parentFile + ) + temporary = temporaryFile + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(temporaryFile).use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Unable to open the selected document") + if (!overwrite) { + reservedDestination = destination.createNewFile() + if (!reservedDestination) { + throw IllegalStateException("The destination already exists") + } + } + if (!temporaryFile.renameTo(destination)) { + if (reservedDestination) { + destination.delete() + } + errorCode = "rename_failed" + throw IllegalStateException("Unable to replace the destination") + } + runOnUiThread { result.success(true) } + } catch (e: Exception) { + Log.e(logTag, "Failed to import file", e) + runOnUiThread { + result.error(errorCode, e.message, null) + } + } finally { + temporary?.delete() + } + } + } + } + EXPORT_FILE -> { + val path = (call.arguments as? Map<*, *>)?.get("path") as? String + val source = path?.let { canonicalExportSource(it) } + + if (source?.isFile != true) { + result.error("invalid_source", "The file is outside app-scoped storage", null) + } else if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + val mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(source.extension.lowercase()) + ?: "application/octet-stream" + pendingPicker = PendingPicker.ExportFile(source, result) + try { + startActivityForResult( + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + putExtra(Intent.EXTRA_TITLE, source.name) + }, + REQ_EXPORT_FILE + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + PICK_IMPORT_DIRECTORY -> { + if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ImportDirectory(result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + putExtra(Intent.EXTRA_TITLE, "Select the folder to import") + }, + REQ_IMPORT_DIRECTORY + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + IMPORT_DIRECTORY -> { + val arguments = call.arguments as? Map<*, *> + val uri = (arguments?.get("uri") as? String)?.let { + runCatching { Uri.parse(it) }.getOrNull() + } + val path = arguments?.get("path") as? String + val overwrite = arguments?.get("overwrite") as? Boolean ?: false + val destination = path?.let { canonicalAppScopedFile(it) } + + if (uri?.scheme != "content") { + result.error("invalid_uri", "The selected document URI is invalid", null) + } else if (destination == null || + destination.parentFile?.isDirectory != true || + (destination.exists() && !destination.isDirectory)) { + result.error("invalid_destination", "The destination is outside app-scoped storage", null) + } else { + thread { + var temporary: File? = null + var backup: File? = null + val ok = try { + val parent = destination.parentFile + ?: throw IllegalStateException("The destination has no parent") + temporary = File.createTempFile( + ".rustdesk-import-dir-", + ".tmp", + parent + ).also { + if (!it.delete() || !it.mkdir()) { + throw IllegalStateException("Unable to create a temporary folder") + } + } + if (!copyDocumentTreeToFile(uri, temporary!!)) { + throw IllegalStateException("Unable to read all folder contents") + } + if (destination.exists()) { + if (!overwrite) { + throw IllegalStateException("The destination already exists") + } + val backupFile = File.createTempFile( + ".rustdesk-import-backup-", + ".tmp", + parent + ) + if (!backupFile.delete()) { + throw IllegalStateException("Unable to prepare the destination backup") + } + backup = backupFile + if (!destination.renameTo(backupFile)) { + throw IllegalStateException("Unable to replace the destination") + } + } + if (!temporary!!.renameTo(destination)) { + val destinationBackup = backup + if (destinationBackup != null && + !destinationBackup.renameTo(destination) + ) { + throw IllegalStateException( + "Unable to move the imported folder and restore " + + "the destination from $destinationBackup" + ) + } + throw IllegalStateException("Unable to move the imported folder") + } + temporary = null + val destinationBackup = backup + if (destinationBackup != null && + !destinationBackup.deleteRecursively() + ) { + throw IllegalStateException( + "Unable to remove the destination backup: $destinationBackup" + ) + } + backup = null + true + } catch (e: Exception) { + Log.e(logTag, "Failed to import directory", e) + false + } finally { + temporary?.deleteRecursively() + } + runOnUiThread { result.success(ok) } + } + } + } + EXPORT_FILES -> { + val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*> + if (paths.isNullOrEmpty()) { + result.error("invalid_source", "The selected files are outside app-scoped storage", null) + } else { + val sources = paths.mapNotNull { + (it as? String)?.let(::canonicalExportSource) + } + val rejected = paths.size - sources.size + if (sources.isEmpty()) { + result.success(mapOf("exported" to 0, "failed" to rejected)) + } else if (pendingPicker != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + } else { + pendingPicker = PendingPicker.ExportFiles(sources, rejected, result) + try { + startActivityForResult( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + putExtra(Intent.EXTRA_TITLE, "Select the destination folder") + }, + REQ_EXPORT_FILES + ) + } catch (e: Exception) { + pendingPicker = null + result.error("picker_unavailable", e.message, null) + } + } + } + } GET_VALUE -> { if (call.arguments is String) { if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) { @@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() { } } + private fun canonicalAppScopedFile(path: String): File? { + val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null + val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull { + runCatching { it.canonicalFile }.getOrNull() + } + return file.takeIf { candidate -> + allowedRoots.any { root -> + candidate == root || candidate.path.startsWith(root.path + File.separator) + } + } + } + + private fun canonicalExportSource(path: String): File? { + val original = File(path).absoluteFile + val canonical = canonicalAppScopedFile(path) ?: return null + return canonical.takeIf { + original.path == canonical.path && (canonical.isFile || canonical.isDirectory) + } + } + + private fun snapshotExportSource(source: File): ExportSource? { + val safeSource = canonicalExportSource(source.path) ?: return null + if (safeSource.isFile) return ExportSource(safeSource, null) + val sourceChildren = safeSource.listFiles() ?: return null + val children = ArrayList(sourceChildren.size) + for (child in sourceChildren) { + val snapshot = snapshotExportSource(child) ?: return null + children.add(snapshot) + } + return ExportSource(safeSource, children) + } + + private fun copyExportSourceToTree( + treeUri: Uri, + parentDocId: String, + source: ExportSource + ): Boolean { + val children = source.children + return if (children == null) { + copyFileToTree(treeUri, parentDocId, source.file) + } else { + copyDirToTree(treeUri, parentDocId, source) + } + } + + private fun treeDisplayName(treeUri: Uri): String? { + return try { + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId) + contentResolver.query( + docUri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + } catch (e: Exception) { + Log.w(logTag, "Failed to read selected folder name", e) + null + } + } + + private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean { + val rootDocId = DocumentsContract.getTreeDocumentId(treeUri) + return copyChildrenToFile(treeUri, rootDocId, destinationDir) + } + + private fun copyChildrenToFile( + treeUri: Uri, + parentDocId: String, + destinationDir: File + ): Boolean { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + var ok = true + val destinationNames = HashSet() + val cursor = contentResolver.query(childrenUri, childColumns, null, null, null) + ?: return false + cursor.use { + while (cursor.moveToNext()) { + val docId = cursor.getString(0) + val name = cursor.getString(1) + val mime = cursor.getString(2) + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId) + if (name != null && !destinationNames.add(name)) { + ok = false + continue + } + val destination = safeDestinationChild(destinationDir, name) + if (destination == null || destination.exists()) { + ok = false + continue + } + if (mime == DocumentsContract.Document.MIME_TYPE_DIR) { + if (!destination.mkdirs() && !destination.isDirectory) { + ok = false + continue + } + if (!copyChildrenToFile(treeUri, docId, destination)) { + ok = false + } + } else if (!copyDocumentToFile(docUri, destination)) { + ok = false + } + } + } + return ok + } + + private fun safeDestinationChild(destinationDir: File, name: String?): File? { + if (name.isNullOrEmpty() || name == "." || name == ".." || + name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) { + return null + } + val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null + val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null + return child.takeIf { it.path.startsWith(parent.path + File.separator) } + } + + private fun copyDocumentToFile(uri: Uri, destination: File): Boolean { + return try { + destination.parentFile?.mkdirs() + if (destination.exists() && !destination.delete()) { + return false + } + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destination).use { output -> input.copyTo(output) } + } != null + } catch (e: Exception) { + Log.e(logTag, "Failed to copy document to $destination", e) + false + } + } + + private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean { + val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false + return try { + val mime = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(safeSource.extension.lowercase()) + ?: "application/octet-stream" + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId) + val docUri = DocumentsContract.createDocument( + contentResolver, + parentUri, + mime, + safeSource.name + ) ?: return false + contentResolver.openOutputStream(docUri, "wt")?.use { output -> + FileInputStream(safeSource).use { input -> input.copyTo(output) } + } ?: return false + true + } catch (e: Exception) { + Log.e(logTag, "Failed to export file $safeSource", e) + false + } + } + + private fun copyDirToTree( + treeUri: Uri, + parentDocId: String, + source: ExportSource + ): Boolean { + val children = source.children ?: return false + val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory } + ?: return false + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId) + var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name) + if (dirDocId == null) { + dirDocId = try { + DocumentsContract.createDocument( + contentResolver, + parentUri, + DocumentsContract.Document.MIME_TYPE_DIR, + safeSource.name + )?.let { DocumentsContract.getDocumentId(it) } + } catch (e: Exception) { + Log.e(logTag, "Failed to create folder ${safeSource.name}", e) + null + } + } + if (dirDocId == null) return false + + var ok = true + children.forEach { child -> + val childOk = copyExportSourceToTree(treeUri, dirDocId, child) + if (!childOk) ok = false + } + return ok + } + + private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId) + val cursor = contentResolver.query(childrenUri, childColumns, null, null, null) + ?: throw IllegalStateException("Unable to query destination folder") + cursor.use { + while (cursor.moveToNext()) { + if (cursor.getString(1) == name && + cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR + ) { + return cursor.getString(0) + } + } + } + return null + } + + private val childColumns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + + private fun displayName(uri: Uri): String? { + return try { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (e: Exception) { + Log.w(logTag, "Failed to read selected document name", e) + null + } + } + private fun setCodecInfo() { val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) val codecs = codecList.codecInfos 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 b03b63844..cfee6ab47 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 @@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.content.res.Configuration import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.graphics.Color @@ -150,7 +151,7 @@ class MainService : Service() { if (incomingVoiceCall) { voiceCallRequestNotification(id, "Voice Call Request", username, peerId) } else { - if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) { + if (!switchOutVoiceCall()) { Log.e(logTag, "switchOutVoiceCall fail") MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( "type" to "custom-nook-nocancel-hasclose-error", @@ -159,7 +160,7 @@ class MainService : Service() { } } } else { - if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) { + if (!switchToVoiceCall()) { Log.e(logTag, "switchToVoiceCall fail") MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( "type" to "custom-nook-nocancel-hasclose-error", @@ -214,6 +215,19 @@ class MainService : Service() { // video private var mediaProjection: MediaProjection? = null + private var mediaProjectionCallback: MediaProjection.Callback? = null + private var captureRestartPending = false + private var captureRestartInVoiceCall = false + private val mediaProjectionResultReceiver = + object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + if (resultCode == RES_FAILED) { + cancelMediaProjectionRecovery() + } + } + } + private var mediaProjectionForegroundService = false + private var microphoneForegroundService = false private var surface: Surface? = null private val sendVP9Thread = Executors.newSingleThreadExecutor() private var videoEncoder: MediaCodec? = null @@ -243,7 +257,9 @@ class MainService : Service() { // keep the config dir same with flutter val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE) val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: "" - FFI.startServer(configPath, "") + val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath + ?: applicationContext.filesDir.absolutePath + FFI.startServer(configPath, homePath, "") createForegroundNotification() } @@ -337,8 +353,6 @@ class MainService : Service() { Log.d("whichService", "this service: ${Thread.currentThread()}") super.onStartCommand(intent, flags, startId) if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) { - createForegroundNotification() - if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) { FFI.startService() } @@ -347,10 +361,7 @@ class MainService : Service() { getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { - mediaProjection = - mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it) - checkMediaPermission() - _isReady = true + replaceMediaProjection(mediaProjectionManager, it) } ?: let { Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") requestMediaProjection() @@ -364,14 +375,23 @@ class MainService : Service() { updateScreenInfo(newConfig.orientation) } - private fun requestMediaProjection() { + private fun requestMediaProjection(recovery: Boolean = false) { val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply { action = ACT_REQUEST_MEDIA_PROJECTION flags = Intent.FLAG_ACTIVITY_NEW_TASK + if (recovery) { + putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver) + } } startActivity(intent) } + @Synchronized + private fun cancelMediaProjectionRecovery() { + captureRestartPending = false + captureRestartInVoiceCall = false + } + @SuppressLint("WrongConstant") private fun createSurface(): Surface? { return if (useVP9) { @@ -405,15 +425,149 @@ class MainService : Service() { } } - fun onVoiceCallStarted(): Boolean { - return audioRecordHandle.onVoiceCallStarted(mediaProjection) + private fun releaseMediaProjection() { + val projection = mediaProjection + val callback = mediaProjectionCallback + mediaProjection = null + mediaProjectionCallback = null + if (projection != null && callback != null) { + projection.unregisterCallback(callback) + } + projection?.stop() } + @Synchronized + private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) { + if (mediaProjection !== stoppedProjection) { + return + } + Log.d(logTag, "MediaProjection stopped") + setMediaProjectionForegroundService(false) + stopCapture() + virtualDisplay?.release() + virtualDisplay = null + mediaProjection = null + mediaProjectionCallback = null + _isReady = false + checkMediaPermission() + } + + @Synchronized + private fun replaceMediaProjection( + mediaProjectionManager: MediaProjectionManager, + resultIntent: Intent, + ) { + val wasCapturing = isStart + val restartCapture = wasCapturing || captureRestartPending + val restartInVoiceCall = if (wasCapturing) { + audioRecordHandle.isVoiceCallActive() + } else { + captureRestartInVoiceCall + } + val hadProjection = mediaProjection != null + if (!setMediaProjectionForegroundService(true)) { + if (!hadProjection) { + cancelMediaProjectionRecovery() + _isReady = false + checkMediaPermission() + } + return + } + val projection = + mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent) + if (projection == null) { + if (!hadProjection) { + cancelMediaProjectionRecovery() + _isReady = false + setMediaProjectionForegroundService(false) + checkMediaPermission() + } + return + } + if (wasCapturing) { + stopCapture() + } + captureRestartPending = restartCapture + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + val callback = object : MediaProjection.Callback() { + override fun onStop() { + handleMediaProjectionStopped(projection) + } + } + projection.registerCallback(callback, Handler(Looper.getMainLooper())) + mediaProjection = projection + mediaProjectionCallback = callback + _isReady = true + checkMediaPermission() + if (restartCapture) { + captureRestartPending = false + startCapture(restartInVoiceCall) + } + } + + @Synchronized + private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean { + if (!setMicrophoneForegroundService(true)) { + return false + } + if (startAudio()) { + return true + } + setMicrophoneForegroundService(false) + return false + } + + @Synchronized + private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean { + val stopped = stopAudio() + val foregroundServiceUpdated = setMicrophoneForegroundService(false) + return stopped && foregroundServiceUpdated + } + + @Synchronized + private fun switchToVoiceCall(): Boolean { + if (captureRestartPending) { + captureRestartInVoiceCall = true + } + return startMicrophoneCapture { + audioRecordHandle.switchToVoiceCall(mediaProjection) + } + } + + @Synchronized + private fun switchOutVoiceCall(): Boolean { + captureRestartInVoiceCall = false + val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection) + val foregroundServiceUpdated = setMicrophoneForegroundService(false) + return switched && foregroundServiceUpdated + } + + @Synchronized + fun onVoiceCallStarted(): Boolean { + if (captureRestartPending) { + captureRestartInVoiceCall = true + } + return startMicrophoneCapture { + audioRecordHandle.onVoiceCallStarted(mediaProjection) + } + } + + @Synchronized fun onVoiceCallClosed(): Boolean { - return audioRecordHandle.onVoiceCallClosed(mediaProjection) + captureRestartInVoiceCall = false + return stopMicrophoneCapture { + audioRecordHandle.onVoiceCallClosed(mediaProjection) + } } fun startCapture(): Boolean { + return startCapture(false) + } + + @Synchronized + private fun startCapture(inVoiceCall: Boolean): Boolean { if (isStart) { return true } @@ -421,25 +575,35 @@ class MainService : Service() { Log.w(logTag, "startCapture fail,mediaProjection is null") return false } + captureRestartInVoiceCall = inVoiceCall updateScreenInfo(resources.configuration.orientation) Log.d(logTag, "Start Capture") surface = createSurface() - if (useVP9) { + val videoStarted = if (useVP9) { startVP9VideoRecorder(mediaProjection!!) } else { startRawVideoRecorder(mediaProjection!!) } + if (!videoStarted) { + if (!captureRestartPending) { + captureRestartInVoiceCall = false + } + releaseFailedVideoCapture() + return false + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) { - Log.d(logTag, "createAudioRecorder fail") + val audioStarted = if (inVoiceCall) { + switchToVoiceCall() } else { - Log.d(logTag, "audio recorder start") - audioRecordHandle.startAudioRecorder() + audioRecordHandle.createAudioRecorder(false, mediaProjection) && + audioRecordHandle.startAudioRecorder() } + Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed") } + captureRestartInVoiceCall = false checkMediaPermission() _isStart = true FFI.setFrameRawEnable("video",true) @@ -447,9 +611,24 @@ class MainService : Service() { return true } + private fun releaseFailedVideoCapture() { + imageReader?.close() + imageReader = null + videoEncoder?.let { + it.signalEndOfInputStream() + it.stop() + it.release() + } + videoEncoder = null + surface?.release() + surface = null + } + @Synchronized fun stopCapture() { Log.d(logTag, "Stop Capture") + captureRestartPending = false + captureRestartInVoiceCall = false FFI.setFrameRawEnable("video",false) _isStart = false MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) @@ -480,8 +659,11 @@ class MainService : Service() { surface?.release() // release audio - _isAudioStart = false - audioRecordHandle.tryReleaseAudio() + stopMicrophoneCapture { + _isAudioStart = false + audioRecordHandle.tryReleaseAudio() + true + } } fun destroy() { @@ -496,7 +678,9 @@ class MainService : Service() { virtualDisplay = null } - mediaProjection = null + releaseMediaProjection() + mediaProjectionForegroundService = false + microphoneForegroundService = false checkMediaPermission() stopForeground(true) stopService(Intent(this, FloatingWindowService::class.java)) @@ -519,49 +703,70 @@ class MainService : Service() { return isReady } - private fun startRawVideoRecorder(mp: MediaProjection) { + private fun startRawVideoRecorder(mp: MediaProjection): Boolean { Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO") - if (surface == null) { + val captureSurface = surface + if (captureSurface == null) { Log.d(logTag, "startRawVideoRecorder failed,surface is null") - return + return false } - createOrSetVirtualDisplay(mp, surface!!) + return createOrSetVirtualDisplay(mp, captureSurface) } - private fun startVP9VideoRecorder(mp: MediaProjection) { + private fun startVP9VideoRecorder(mp: MediaProjection): Boolean { createMediaCodec() - videoEncoder?.let { - surface = it.createInputSurface() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) - } - it.setCallback(cb) - it.start() - createOrSetVirtualDisplay(mp, surface!!) + val encoder = videoEncoder ?: return false + val inputSurface = encoder.createInputSurface() + surface = inputSurface + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) } + encoder.setCallback(cb) + encoder.start() + return createOrSetVirtualDisplay(mp, inputSurface) } // https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250 // Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection. - private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) { - try { - virtualDisplay?.let { - it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) - it.setSurface(s) - } ?: let { - virtualDisplay = mp.createVirtualDisplay( + private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean { + return try { + val existingDisplay = virtualDisplay + if (existingDisplay != null) { + existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) + existingDisplay.setSurface(s) + true + } else { + val display = mp.createVirtualDisplay( "RustDeskVD", SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, s, null, null ) + if (display == null) { + Log.e(logTag, "createOrSetVirtualDisplay failed") + handleVirtualDisplayFailure() + } else { + virtualDisplay = display + true + } } } catch (e: SecurityException) { - Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation"); - // This initiates a prompt dialog for the user to confirm screen projection. - requestMediaProjection() + Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e) + handleVirtualDisplayFailure() } } + private fun handleVirtualDisplayFailure(): Boolean { + captureRestartPending = true + virtualDisplay?.release() + virtualDisplay = null + releaseMediaProjection() + setMediaProjectionForegroundService(false) + _isReady = false + checkMediaPermission() + requestMediaProjection(true) + return false + } + private val cb: MediaCodec.Callback = object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {} override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {} @@ -652,7 +857,63 @@ class MainService : Service() { .setColor(ContextCompat.getColor(this, R.color.primary)) .setWhen(System.currentTimeMillis()) .build() - startForeground(DEFAULT_NOTIFY_ID, notification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType()) + } else { + startForeground(DEFAULT_NOTIFY_ID, notification) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun foregroundServiceType(): Int { + var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Keep a valid FGS type while the unattended host is idle and no capture type is active. + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } + if (mediaProjectionForegroundService) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + return serviceType + } + + private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean { + return updateForegroundServiceTypes(enabled, microphoneForegroundService) + } + + private fun setMicrophoneForegroundService(enabled: Boolean): Boolean { + return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled) + } + + private fun updateForegroundServiceTypes( + mediaProjectionEnabled: Boolean, + microphoneEnabled: Boolean, + ): Boolean { + if (mediaProjectionForegroundService == mediaProjectionEnabled && + microphoneForegroundService == microphoneEnabled) { + return true + } + val previousMediaProjection = mediaProjectionForegroundService + val previousMicrophone = microphoneForegroundService + mediaProjectionForegroundService = mediaProjectionEnabled + microphoneForegroundService = microphoneEnabled + return try { + createForegroundNotification() + true + } catch (error: SecurityException) { + mediaProjectionForegroundService = previousMediaProjection + microphoneForegroundService = previousMicrophone + Log.e(logTag, "Failed to update foreground service types", error) + false + } catch (error: IllegalStateException) { + mediaProjectionForegroundService = previousMediaProjection + microphoneForegroundService = previousMicrophone + Log.e(logTag, "Failed to update foreground service types", error) + false + } } private fun loginRequestNotification( diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt index 3beb7ec6b..9034d2096 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.media.projection.MediaProjectionManager import android.os.Build import android.os.Bundle +import android.os.ResultReceiver import android.util.Log class PermissionRequestTransparentActivity: Activity() { @@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() { if (resultCode == RESULT_OK && data != null) { launchService(data) } else { - setResult(RES_FAILED) + val resultReceiver = + intent.getParcelableExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER) + if (resultReceiver != null) { + resultReceiver.send(RES_FAILED, null) + } else { + setResult(RES_FAILED) + } } } @@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() { } } -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 514d493b9..b59dca945 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT" const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT" +const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER" const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY" // Activity requestCode const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101 const val REQ_REQUEST_MEDIA_PROJECTION = 201 +const val REQ_EXPORT_FILE = 301 +const val REQ_IMPORT_FILES = 302 +const val REQ_IMPORT_DIRECTORY = 303 +const val REQ_EXPORT_FILES = 304 // Activity responseCode const val RES_FAILED = -100 @@ -47,6 +52,12 @@ const val START_ACTION = "start_action" const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt" const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt" const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir" +const val PICK_IMPORT_FILES = "pick_import_files" +const val IMPORT_FILE = "import_file" +const val EXPORT_FILE = "export_file" +const val PICK_IMPORT_DIRECTORY = "pick_import_directory" +const val IMPORT_DIRECTORY = "import_directory" +const val EXPORT_FILES = "export_files" const val GET_VALUE = "get_value" const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL" @@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair{ fun translate(input: String): String { Log.d("common", "translate:$LOCAL_NAME") return FFI.translateLocale(LOCAL_NAME, input) -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index 89e3dc046..02e6606ae 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -15,7 +15,7 @@ object FFI { external fun init(ctx: Context) external fun onAppStart(ctx: Context) external fun setClipboardManager(clipboardManager: RdClipboardManager) - external fun startServer(app_dir: String, custom_client_config: String) + external fun startServer(app_dir: String, home_dir: String, custom_client_config: String) external fun startService() external fun onVideoFrameUpdate(buf: ByteBuffer) external fun onAudioFrameUpdate(buf: ByteBuffer) diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml index 3e058a81b..eae55d590 100644 --- a/flutter/android/app/src/main/res/values/strings.xml +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ RustDesk Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established + Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval. diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 401bea009..5733740c2 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -1,3 +1,29 @@ +def legacyPluginNamespaces = [ + external_path: 'com.pinciat.external_path', + flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility', + qr_code_scanner: 'net.touchcapture.qr.flutterqr', + sqflite: 'com.tekartik.sqflite', + uni_links: 'name.avioli.unilinks', +] + +def java8JvmTarget = JavaVersion.VERSION_1_8.toString() +def java8KotlinJvmTargets = [ + app: java8JvmTarget, + external_path: java8JvmTarget, + qr_code_scanner: java8JvmTarget, +] + +def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget -> + project.plugins.withId('kotlin-android') { + project.tasks.configureEach { task -> + if (!task.hasProperty('kotlinOptions')) { + return + } + task.kotlinOptions.jvmTarget = kotlinJvmTarget + } + } +} + allprojects { repositories { google() @@ -9,6 +35,16 @@ allprojects { rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" + def legacyNamespace = legacyPluginNamespaces[project.name] + if (legacyNamespace != null) { + project.plugins.withId('com.android.library') { + project.android.namespace = legacyNamespace + } + } + def kotlinJvmTarget = java8KotlinJvmTargets[project.name] + if (kotlinJvmTarget != null) { + configureKotlinJvmTarget(project, kotlinJvmTarget) + } } subprojects { project.evaluationDependsOn(':app') diff --git a/flutter/android/gradle/wrapper/gradle-wrapper.properties b/flutter/android/gradle/wrapper/gradle-wrapper.properties index cb576305f..9162f1008 100644 --- a/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/flutter/android/settings.gradle b/flutter/android/settings.gradle index ae32fa00e..b72bea584 100644 --- a/flutter/android/settings.gradle +++ b/flutter/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.1" apply false + id "com.android.application" version "8.10.1" apply false id "org.jetbrains.kotlin.android" version "2.1.21" apply false } diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 94c3c2a72..25eed4259 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6; bool get isMainDesktopWindow => desktopType == DesktopType.main || desktopType == DesktopType.cm; -String get screenInfo => screenInfo_; - /// Check if the app is running with single view mode. bool isSingleViewApp() { return desktopType == DesktopType.cm; @@ -1521,13 +1519,6 @@ class AndroidPermissionManager { static Timer? _timer; static var _current = ""; - static bool isWaitingFile() { - if (_completer != null) { - return !_completer!.isCompleted && _current == kManageExternalStorage; - } - return false; - } - static Future check(String type) { if (isDesktop || isWeb) { return Future.value(true); @@ -2636,13 +2627,6 @@ connect(BuildContext context, String id, } } else { if (isFileTransfer) { - if (isAndroid) { - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - if (!await AndroidPermissionManager.request(kManageExternalStorage)) { - return; - } - } - } if (isWeb) { Navigator.push( context, diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index f80603802..98d7f6b4b 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -936,26 +936,19 @@ void enterPasswordDialog( ); } -void enterUserLoginDialog( - SessionID sessionId, - OverlayDialogManager dialogManager, - String osAccountDescTip, - bool canRememberAccount) async { +void enterUserLoginDialog(SessionID sessionId, + OverlayDialogManager dialogManager, String osAccountDescTip) async { await _connectDialog( sessionId, dialogManager, osUsernameController: TextEditingController(), osPasswordController: TextEditingController(), osAccountDescTip: osAccountDescTip, - canRememberAccount: canRememberAccount, ); } -void enterUserLoginAndPasswordDialog( - SessionID sessionId, - OverlayDialogManager dialogManager, - String osAccountDescTip, - bool canRememberAccount) async { +void enterUserLoginAndPasswordDialog(SessionID sessionId, + OverlayDialogManager dialogManager, String osAccountDescTip) async { await _connectDialog( sessionId, dialogManager, @@ -963,7 +956,6 @@ void enterUserLoginAndPasswordDialog( osPasswordController: TextEditingController(), passwordController: TextEditingController(), osAccountDescTip: osAccountDescTip, - canRememberAccount: canRememberAccount, ); } @@ -974,7 +966,6 @@ _connectDialog( TextEditingController? osPasswordController, TextEditingController? passwordController, String? osAccountDescTip, - bool canRememberAccount = true, }) async { final errUsername = ''.obs; var rememberPassword = false; @@ -982,11 +973,6 @@ _connectDialog( rememberPassword = await bind.sessionGetRemember(sessionId: sessionId) ?? false; } - var rememberAccount = false; - if (canRememberAccount && osUsernameController != null) { - rememberAccount = - await bind.sessionGetRemember(sessionId: sessionId) ?? false; - } if (osUsernameController != null) { osUsernameController.addListener(() { if (errUsername.value.isNotEmpty) { @@ -1014,12 +1000,6 @@ _connectDialog( final osPassword = osPasswordController?.text.trim() ?? ''; final password = passwordController?.text.trim() ?? ''; if (passwordController != null && password.isEmpty) return; - if (rememberAccount) { - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-username', value: osUsername); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-password', value: osPassword); - } gFFI.login( osUsername, osPassword, @@ -1096,16 +1076,6 @@ _connectDialog( controller: osPasswordController, autoFocus: false, ), - if (canRememberAccount) - rememberWidget( - translate('remember_account_tip'), - rememberAccount, - (v) { - if (v != null) { - setState(() => rememberAccount = v); - } - }, - ), ], ); } @@ -1542,91 +1512,6 @@ showSetOSPassword( }); } -showSetOSAccount( - SessionID sessionId, - OverlayDialogManager dialogManager, -) async { - final usernameController = TextEditingController(); - final passwdController = TextEditingController(); - var username = - await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ?? - ''; - var password = - await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ?? - ''; - usernameController.text = username; - passwdController.text = password; - dialogManager.show((setState, close, context) { - submit() { - final username = usernameController.text.trim(); - final password = usernameController.text.trim(); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-username', value: username); - bind.sessionPeerOption( - sessionId: sessionId, name: 'os-password', value: password); - close(); - } - - descWidget(String text) { - return Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - text, - maxLines: 3, - softWrap: true, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 16), - ), - ), - Container( - height: 8, - ), - ], - ); - } - - return CustomAlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.password_rounded, color: MyTheme.accent), - Text(translate('OS Account')).paddingOnly(left: 10), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - descWidget(translate("os_account_desk_tip")), - DialogTextField( - title: translate(DialogTextField.kUsernameTitle), - controller: usernameController, - prefixIcon: DialogTextField.kUsernameIcon, - errorText: null, - ), - PasswordWidget(controller: passwdController), - ], - ), - actions: [ - dialogButton( - "Cancel", - icon: Icon(Icons.close_rounded), - onPressed: close, - isOutline: true, - ), - dialogButton( - "OK", - icon: Icon(Icons.done_rounded), - onPressed: submit, - ), - ], - onSubmit: submit, - onCancel: close, - ); - }); -} - Widget buildNoteTextField({ required TextEditingController controller, required VoidCallback onEscape, @@ -2014,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async { msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]); } -trackpadSpeedDialog(SessionID sessionId, FFI ffi) async { - int initSpeed = ffi.inputModel.trackpadSpeed; +int? _validateTrackpadSpeed(String text) { + final speed = int.tryParse(text); + if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) { + BotToast.showText( + text: + '${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed', + contentColor: Colors.red, + ); + return null; + } + return speed; +} + +Future _saveTrackpadSpeed({ + required SessionID sessionId, + required FFI ffi, + required int initSpeed, + required int speed, +}) async { + if (speed == initSpeed) { + return; + } + await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed); + await ffi.inputModel.updateTrackpadSpeed(); +} + +void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) { + debugPrint('Failed to save trackpad speed: $error'); + debugPrintStack(stackTrace: stackTrace); + BotToast.showText( + text: translate('Failed'), + contentColor: Colors.red, + ); +} + +List _trackpadSpeedDialogActions({ + required bool isSubmitting, + required VoidCallback close, + required VoidCallback submit, +}) { + return [ + dialogButton( + 'Cancel', + icon: Icon(Icons.close_rounded), + onPressed: isSubmitting ? null : close, + isOutline: true, + ), + dialogButton( + 'OK', + icon: Icon(Icons.done_rounded), + onPressed: isSubmitting ? null : submit, + ), + ]; +} + +void trackpadSpeedDialog(SessionID sessionId, FFI ffi) { + final initSpeed = ffi.inputModel.trackpadSpeed; final curSpeed = SimpleWrapper(initSpeed); - final btnClose = dialogButton('Close', onPressed: () async { - if (curSpeed.value <= kMaxTrackpadSpeed && - curSpeed.value >= kMinTrackpadSpeed && - curSpeed.value != initSpeed) { - await bind.sessionSetTrackpadSpeed( - sessionId: sessionId, value: curSpeed.value); - await ffi.inputModel.updateTrackpadSpeed(); + var speedText = initSpeed.toString(); + var isSubmitting = false; + ffi.dialogManager.show((setState, close, context) { + Future submit([String? submittedText]) async { + if (isSubmitting) { + return; + } + speedText = submittedText ?? speedText; + final speed = _validateTrackpadSpeed(speedText); + if (speed == null) { + return; + } + setState(() => isSubmitting = true); + try { + await _saveTrackpadSpeed( + sessionId: sessionId, + ffi: ffi, + initSpeed: initSpeed, + speed: speed, + ); + close(); + } catch (error, stackTrace) { + _showTrackpadSpeedSaveError(error, stackTrace); + setState(() => isSubmitting = false); + } } - ffi.dialogManager.dismissAll(); - }); - msgBoxCommon( - ffi.dialogManager, - 'Trackpad speed', - TrackpadSpeedWidget( - value: curSpeed, + + return CustomAlertDialog( + title: Text( + translate('Trackpad speed'), + style: TextStyle(fontSize: 21), ), - [btnClose]); + content: TrackpadSpeedWidget( + value: curSpeed, + onTextChanged: (text) => speedText = text, + onTextSubmitted: submit, + ), + actions: _trackpadSpeedDialogActions( + isSubmitting: isSubmitting, + close: close, + submit: submit, + ), + onSubmit: isSubmitting ? null : submit, + onCancel: isSubmitting ? null : close, + ); + }); } void deleteConfirmDialog(Function onSubmit, String title) async { diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index 5871033db..1e2daac5d 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState InputModel get inputModel => widget.inputModel; bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode; SessionID get sessionId => ffi.sessionId; + bool get canvasLocked => isMobile && ffi.canvasModel.locked; @override Widget build(BuildContext context) { @@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState return; } + if (canvasLocked) return; + if ((isDesktop || isWebDesktop)) { final scale = ((d.scale - _scale) * 1000).toInt(); _scale = d.scale; diff --git a/flutter/lib/common/widgets/setting_widgets.dart b/flutter/lib/common/widgets/setting_widgets.dart index f3be77003..9449c3624 100644 --- a/flutter/lib/common/widgets/setting_widgets.dart +++ b/flutter/lib/common/widgets/setting_widgets.dart @@ -253,8 +253,18 @@ class TrackpadSpeedWidget extends StatefulWidget { final SimpleWrapper value; // If null, no debouncer will be applied. final Function(int)? onDebouncer; + final ValueChanged? onTextChanged; + // IME actions call TextField.onSubmitted without reaching the dialog's + // raw Enter handler, so the dialog needs a separate submission callback. + final ValueChanged? onTextSubmitted; - TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer}); + TrackpadSpeedWidget({ + Key? key, + required this.value, + this.onDebouncer, + this.onTextChanged, + this.onTextSubmitted, + }); @override TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState(); @@ -276,6 +286,34 @@ class TrackpadSpeedWidgetState extends State { debouncerSpeed.setValue(value); } }); + widget.onTextChanged?.call(_controller.text); + } + + void updateTextValue(String text) { + widget.onTextChanged?.call(text); + final newValue = int.tryParse(text); + if (newValue == null || + newValue < kMinTrackpadSpeed || + newValue > kMaxTrackpadSpeed) { + return; + } + setState(() => value = newValue); + } + + void submitTextValue(String text) { + final onTextSubmitted = widget.onTextSubmitted; + if (onTextSubmitted != null) { + onTextSubmitted(text); + return; + } + if (widget.onTextChanged != null) { + return; + } + final newValue = int.tryParse(text); + if (newValue == null) { + return; + } + updateValue(newValue); } @override @@ -315,12 +353,8 @@ class TrackpadSpeedWidgetState extends State { controller: _controller, keyboardType: TextInputType.number, textAlign: TextAlign.center, - onSubmitted: (text) { - int? v = int.tryParse(text); - if (v != null) { - updateValue(v); - } - }, + onChanged: updateTextValue, + onSubmitted: submitTextValue, style: const TextStyle(fontSize: 13), decoration: InputDecoration( contentPadding: diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 0e4c5b7a5..c3896b097 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -349,12 +349,12 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { showRequestElevationDialog(sessionId, ffi.dialogManager)), ); } - // osAccount / osPassword + // osPassword if (isDefaultConn && perms['keyboard'] != false) { v.add( TTextMenu( child: Row(children: [ - Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')), + Text(translate('OS Password')), ]), trailingIcon: Transform.scale( scale: (isDesktop || isWebDesktop) ? 0.8 : 1, @@ -363,18 +363,12 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { if (isMobile && Navigator.canPop(context)) { Navigator.pop(context); } - if (pi.isHeadless) { - showSetOSAccount(sessionId, ffi.dialogManager); - } else { - handleOsPasswordEditIcon(sessionId, ffi.dialogManager); - } + handleOsPasswordEditIcon(sessionId, ffi.dialogManager); }, icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null), ), ), - onPressed: () => pi.isHeadless - ? showSetOSAccount(sessionId, ffi.dialogManager) - : handleOsPasswordAction(sessionId, ffi.dialogManager), + onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager), ), ); } diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 6c22057f9..c6f9d9d6b 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -18,7 +18,6 @@ const kKeyMapMode = 'map'; const kKeyTranslateMode = 'translate'; const String kPlatformAdditionsIsWayland = "is_wayland"; -const String kPlatformAdditionsHeadless = "headless"; const String kPlatformAdditionsIsInstalled = "is_installed"; const String kPlatformAdditionsIddImpl = "idd_impl"; const String kPlatformAdditionsRustDeskVirtualDisplays = @@ -55,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal"; const String kWindowMainWindowOnTop = "main_window_on_top"; const String kWindowRefreshCurrentUser = "refresh_current_user"; -const String kWindowGetWindowInfo = "get_window_info"; const String kWindowGetScreenList = "get_screen_list"; // This method is not used, maybe it can be removed. const String kWindowDisableGrabKeyboard = "disable_grab_keyboard"; @@ -164,13 +162,14 @@ const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs"; const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render"; const String kOptionEnableCheckUpdate = "enable-check-update"; const String kOptionAllowAutoUpdate = "allow-auto-update"; -const String kOptionAllowLinuxHeadless = "allow-linux-headless"; const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper"; const String kOptionStopService = "stop-service"; const String kOptionDirectxCapture = "enable-directx-capture"; const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification"; const String kOptionEnableUdpPunch = "enable-udp-punch"; const String kOptionEnableIpv6Punch = "enable-ipv6-punch"; +const String kOptionAllowSyncClipboardBetweenSessions = + "allow-sync-clipboard-between-sessions"; const String kOptionEnableTrustedDevices = "enable-trusted-devices"; const String kOptionShowVirtualMouse = "show-virtual-mouse"; const String kOptionVirtualMouseScale = "virtual-mouse-scale"; @@ -193,6 +192,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings"; const String kOptionHideWebSocketSetting = "hide-websocket-settings"; const String kOptionHideStopService = "hide-stop-service"; const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings"; +const String kOptionHideGeneralSetting = "hide-general-settings"; const String kOptionHideSecuritySetting = "hide-security-settings"; const String kOptionHideNetworkSetting = "hide-network-settings"; const String kOptionRemovePresetPasswordWarning = @@ -325,10 +325,11 @@ double kNewWindowOffset = isWindows ? 30.0 : 50.0; +const kDragToResizeAreaPaddingSize = 5.0; EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux ? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value ? EdgeInsets.zero - : EdgeInsets.all(5.0) + : EdgeInsets.all(kDragToResizeAreaPaddingSize) : EdgeInsets.zero; // https://en.wikipedia.org/wiki/Non-breaking_space const int $nbsp = 0x00A0; @@ -440,7 +441,6 @@ const kActionApplicationDetailsSettings = const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS"; const kRecordAudio = "android.permission.RECORD_AUDIO"; -const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE"; const kRequestIgnoreBatteryOptimizations = "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"; const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW"; @@ -452,6 +452,12 @@ class AndroidChannel { static final kGetStartOnBootOpt = "get_start_on_boot_opt"; static final kSetStartOnBootOpt = "set_start_on_boot_opt"; static final kSyncAppDirConfigPath = "sync_app_dir"; + static final kPickImportFiles = "pick_import_files"; + static final kImportFile = "import_file"; + static final kExportFile = "export_file"; + static final kPickImportDirectory = "pick_import_directory"; + static final kImportDirectory = "import_directory"; + static final kExportFiles = "export_files"; } /// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 42ec10032..6d370cbb0 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([ @@ -782,13 +780,6 @@ class _DesktopHomePageState extends State windowOnTop(null); } else if (call.method == kWindowRefreshCurrentUser) { gFFI.userModel.refreshCurrentUser(); - } else if (call.method == kWindowGetWindowInfo) { - final screen = (await window_size.getWindowInfo()).screen; - if (screen == null) { - return ''; - } else { - return jsonEncode(screenToMap(screen)); - } } else if (call.method == kWindowGetScreenList) { return jsonEncode( (await window_size.getScreenList()).map(screenToMap).toList()); @@ -890,21 +881,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 b2aab1cfb..256e3923f 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, @@ -64,7 +61,8 @@ enum SettingsTabKey { class DesktopSettingPage extends StatefulWidget { final SettingsTabKey initialTabkey; static final List tabKeys = [ - SettingsTabKey.general, + if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y') + SettingsTabKey.general, if (!isWeb && !bind.isOutgoingOnly() && !bind.isDisableSettings() && @@ -74,10 +72,9 @@ 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.isDisableSettings() && bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') SettingsTabKey.printer, SettingsTabKey.about, @@ -95,7 +92,8 @@ class DesktopSettingPage extends StatefulWidget { if (index == -1) { return; } - if (Get.isRegistered(tag: _kSettingPageControllerTag)) { + if (Get.isRegistered(tag: _kSettingPageControllerTag) && + Get.isRegistered>(tag: _kSettingPageTabKeyTag)) { DesktopTabPage.onAddSetting(initialPage: page); PageController controller = Get.find(tag: _kSettingPageControllerTag); @@ -163,17 +161,23 @@ class _DesktopSettingPageState extends State if (!mounted) { return; } - _canBeBlocked.value = await canBeBlocked(); + final blocked = await canBeBlocked(); + if (!mounted) { + return; + } + _canBeBlocked.value = blocked; }); } @override void dispose() { - super.dispose(); - Get.delete(tag: _kSettingPageControllerTag); - Get.delete(tag: _kSettingPageTabKeyTag); - WidgetsBinding.instance.removeObserver(this); _videoConnTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + Get.delete(tag: _kSettingPageControllerTag); + Get.delete>(tag: _kSettingPageTabKeyTag); + // Get.delete does not dispose a plain ChangeNotifier. + controller.dispose(); + super.dispose(); } List<_TabInfo> _settingTabs() { @@ -196,10 +200,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 +233,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; @@ -578,6 +575,15 @@ class _GeneralState extends State<_General> { kOptionEnableIpv6Punch, isServer: false, ), + Tooltip( + message: translate('sync-clipboard-between-sessions-tip'), + child: _OptionCheckBox( + context, + 'Sync clipboard between sessions', + kOptionAllowSyncClipboardBetweenSessions, + isServer: false, + ), + ), ], ]; @@ -591,10 +597,6 @@ class _GeneralState extends State<_General> { )); } - if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) { - children.add(_OptionCheckBox( - context, 'Allow linux headless', kOptionAllowLinuxHeadless)); - } if (!bind.isDisableAccount()) { children.add(_OptionCheckBox( context, @@ -2255,51 +2257,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/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index cf97351b3..17674c268 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -278,7 +278,39 @@ class _FileManagerPageState extends State item.state != JobState.inProgress, child: LinearPercentIndicator( animateFromLastPercent: true, - center: Text(item.percentText), + center: SizedBox.expand( + child: ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) => + LinearGradient( + colors: [ + Colors.white, + Colors.transparent, + ], + stops: [item.percent, item.percent], + ).createShader(bounds), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text.rich( + TextSpan( + text: item.percentText, + children: [ + if (item.recvJobRes) + TextSpan( + text: + ' ${readableFileSize(item.speed)}/s', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w300, + color: MyTheme.darkGray, + ), + ), + ], + ), + ), + ), + ), + ), barRadius: Radius.circular(15), percent: item.percent, progressColor: MyTheme.accent, @@ -1094,6 +1126,7 @@ class _FileManagerViewState extends State { return element.name.contains(_searchText.value); }).toList(growable: false) : entries; + // Keep rows lazy so large directories only build visible list items. final rows = filteredEntries.map((entry) { final sizeStr = entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; @@ -1276,7 +1309,7 @@ class _FileManagerViewState extends State { ], ))), ); - }).toList(growable: false); + }); return Column( children: [ @@ -1292,7 +1325,7 @@ class _FileManagerViewState extends State { controller: scrollController, itemExtent: kDesktopFileTransferRowHeight, itemBuilder: (context, index) { - return rows[index]; + return rows.elementAt(index); }, itemCount: rows.length, ), diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index a9185d6a3..3e98418b1 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((_) { @@ -274,6 +273,11 @@ class _RemotePageState extends State tabState.tabs[selected].key == widget.id; } + // Every Windows requestFocus() must pass this, or a blocking dialog or an + // inactive tab could hand remote input to this page. + bool get _windowsCanFocusRemoteInput => + _isSelectedTab && _blockableOverlayState.middleBlocked.isFalse; + bool get _isMacOSKeyboardContextActive { return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; } @@ -514,6 +518,15 @@ class _RemotePageState extends State _queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true); } + // Refocus without PointerEnter: the cursor already hovers the image when + // focus returns (Alt+Tab, taskbar), so enterView() never fires again. + if (isWindows && + _cursorOverImage.value && + _windowsCanFocusRemoteInput && + !_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + // Restore relative mouse mode constraints when window regains focus. if (_ffi.inputModel.relativeMouseMode.value) { if (isMacOS) { @@ -524,7 +537,7 @@ class _RemotePageState extends State _cursorOverImage.value = true; _macOSLocalFocusLost = false; } - } else { + } else if (!isWindows || _windowsCanFocusRemoteInput) { _rawKeyFocusNode.requestFocus(); } _ffi.inputModel.onWindowFocus(); @@ -836,7 +849,16 @@ class _RemotePageState extends State _macOSLocalFocusLost = false; stateGlobal.getInputSource(force: true); _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); - } else if (!isWindows) { + } else if (isWindows) { + // Blur unfocuses this node and nothing restores it, so the keyboard stayed + // dead until a click. Focus only while the window is really active, or a + // background window would grab system keys. onFocusChange does enterOrLeave. + if (!_isWindowBlur && + _windowsCanFocusRemoteInput && + !_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + } else { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index a814b9f7e..b1ca18b2b 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -22,6 +22,14 @@ import '../../models/file_model.dart'; import '../../models/platform_model.dart'; import '../../models/server_model.dart'; +/// Set only by this window's own close control, and only once the user has confirmed. Any other +/// way the window can go - a session logout closing every window, the window manager, a native +/// title-bar button this app does not draw - leaves it false, which is the honest answer: +/// nothing in that close says who asked for it. It lives at file scope because the control that +/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`) +/// are different widgets. +bool _cmClosedByOperator = false; + class DesktopServerPage extends StatefulWidget { const DesktopServerPage({Key? key}) : super(key: key); @@ -55,7 +63,10 @@ class _DesktopServerPageState extends State @override void onWindowClose() { - Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) { + // Other platforms keep the old behaviour exactly: the ambiguity this guards against is a + // Linux session logout, which closes every window in the session. + final byOperator = _cmClosedByOperator || !isLinux; + Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) { if (isMacOS) { RdPlatformChannel.instance.terminate(); } else { @@ -327,6 +338,7 @@ class ConnectionManagerState extends State var tabController = gFFI.serverModel.tabController; final connLength = tabController.length; if (connLength <= 1) { + _cmClosedByOperator = true; windowManager.close(); return true; } else { @@ -338,6 +350,9 @@ class ConnectionManagerState extends State res = await closeConfirmDialog(); } if (res) { + // After the dialog, never before it: an external close while it is open must not + // inherit an intent the user had not expressed yet. + _cmClosedByOperator = true; windowManager.close(); } return res; diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index e5e1dbb8d..f193bb23a 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; -import 'package:xterm/xterm.dart'; +import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; import 'terminal_connection_manager.dart'; class TerminalPage extends StatefulWidget { @@ -197,7 +197,7 @@ class _TerminalPageState extends State body: LayoutBuilder( builder: (context, constraints) { final heightPx = constraints.maxHeight; - return TerminalView( + return TerminalMouseInteraction( _terminalModel.terminal, controller: _terminalModel.terminalController, focusNode: _terminalFocusNode, diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart index c45ec4d86..6eb65b11d 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 2373d016a..19b3fa985 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -9,9 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart'; 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'; @@ -1336,6 +1333,12 @@ class ScreenAdjustor { final FFI ffi; final VoidCallback cbExitFullscreen; window_size.Screen? _screen; + Size? _waylandMaximizedWorkAreaSize; + Rect? _waylandWorkAreaScreenFrame; + double? _waylandWorkAreaScaleFactor; + Rect? _x11WorkArea; + Rect? _x11WorkAreaScreenFrame; + double? _x11WorkAreaScaleFactor; ScreenAdjustor({ required this.id, @@ -1346,9 +1349,18 @@ class ScreenAdjustor { bool get isFullscreen => stateGlobal.fullscreen.isTrue; int get windowId => stateGlobal.windowId; + Future isWindowMaximized() async { + try { + return await WindowController.fromWindowId(windowId).isMaximized(); + } catch (_) { + // The delayed resolution callback may run after the window is disposed. + return null; + } + } + adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(), + future: isWindowCanBeAdjusted(context), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1364,36 +1376,201 @@ 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. + // Linux screen and work-area coordinates can use different units or become + // unreliable across Wayland/X11 state changes, so normalize reported frames + // and cache usable work-area measurements before sizing the window. + + Future _updateLinuxWorkAreaCache({ + required window_size.Screen screen, + required Rect wndRect, + required bool isWayland, + required bool isX11, + required bool forMenu, + }) async { + if (isWayland && + (_waylandWorkAreaScreenFrame != screen.frame || + _waylandWorkAreaScaleFactor != screen.scaleFactor)) { + _waylandMaximizedWorkAreaSize = null; + _waylandWorkAreaScreenFrame = screen.frame; + _waylandWorkAreaScaleFactor = screen.scaleFactor; + } + if (isWayland && + forMenu && + !isFullscreen && + await isWindowMaximized() == true) { + _waylandMaximizedWorkAreaSize = wndRect.size; + } + if (isX11 && + (_x11WorkAreaScreenFrame != screen.frame || + _x11WorkAreaScaleFactor != screen.scaleFactor)) { + _x11WorkArea = null; + _x11WorkAreaScreenFrame = screen.frame; + _x11WorkAreaScaleFactor = screen.scaleFactor; + } + if (isX11 && forMenu && !isFullscreen) { + _x11WorkArea = screen.visibleFrame; + } + } + + Future _getEffectiveScreenFrame({ + required window_size.Screen screen, + required bool isWayland, + required bool isX11, + required bool forMenu, + }) async { + Rect frameRect = screen.visibleFrame; + if (isMacOS && forMenu && isFullscreen) { + List? workArea; + try { + workArea = await kMacOSPermChannel + .invokeListMethod('getMacOSWorkAreaSize'); + } catch (_) { + return null; + } + if (workArea == null || workArea.length != 2) { + return null; + } + frameRect = Rect.fromLTWH( + frameRect.left, + frameRect.top, + workArea[0] < frameRect.width ? workArea[0] : frameRect.width, + workArea[1] < frameRect.height ? workArea[1] : frameRect.height, + ); + } + final x11WorkArea = _x11WorkArea; + if (isX11 && + forMenu && + isFullscreen && + x11WorkArea != null && + (x11WorkArea.width < frameRect.width || + x11WorkArea.height < frameRect.height)) { + frameRect = x11WorkArea; + } + final screenScale = screen.scaleFactor; + if (isWayland && screenScale > 1.01) { + String monitorLayoutMode; + try { + monitorLayoutMode = + await bind.mainGetCommon(key: 'gnome-monitor-layout-mode'); + } catch (_) { + monitorLayoutMode = ''; + } + if (monitorLayoutMode == 'physical') { + frameRect = Rect.fromLTRB( + frameRect.left / screenScale, + frameRect.top / screenScale, + frameRect.right / screenScale, + frameRect.bottom / screenScale, + ); + } + } + return frameRect; + } + + Future _getAdjustedWindowFrame(Size mediaSize, + {bool forMenu = false}) 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 isWayland = isLinux && bind.mainCurrentIsWayland(); + final isX11 = isLinux && !isWayland; + await _updateLinuxWorkAreaCache( + screen: screen, + wndRect: wndRect, + isWayland: isWayland, + isX11: isX11, + forMenu: forMenu, + ); + if (isWindows && forMenu && isFullscreen) { + // desktop_multi_window's hidden title bar keeps 8 physical pixels on + // each horizontal edge and at the bottom, plus up to 1px at the top. + // Fullscreen removes these in WM_NCCALCSIZE, so predict the restored + // frame's worst-case padding when deciding whether to show the menu. + magicWidth = 16.0; + magicHeight = 9.0; + } + double horizontalEdges; + double verticalEdges; + if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) { + // Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen + // before resizing. Predict the restored normal-window edges when + // deciding whether to show the menu item. + final resizePadding = isLinux && !kUseCompatibleUiMode + ? kDragToResizeAreaPaddingSize + : 0.0; + final windowEdge = kWindowBorderWidth + resizePadding; + horizontalEdges = windowEdge * 2; + verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2; + } else { + horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge; + verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge; + } final width = (canvasModel.getDisplayWidth() * canvasModel.scale + - CanvasModel.leftToEdge + - CanvasModel.rightToEdge) * + horizontalEdges) * scale + magicWidth; - final height = (canvasModel.getDisplayHeight() * canvasModel.scale + - CanvasModel.topToEdge + - CanvasModel.bottomToEdge) * - scale + - magicHeight; + final height = + (canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) * + scale + + magicHeight; 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; + final frameRect = await _getEffectiveScreenFrame( + screen: screen, + isWayland: isWayland, + isX11: isX11, + forMenu: forMenu, + ); + if (frameRect == null) { + return null; + } + var availableSize = frameRect.size; + if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) { + final cachedSize = _waylandMaximizedWorkAreaSize!; + availableSize = Size( + cachedSize.width < availableSize.width + ? cachedSize.width + : availableSize.width, + cachedSize.height < availableSize.height + ? cachedSize.height + : availableSize.height, + ); + } + // 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; + } + // 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. Reject targets + // that exceed the available area. + final exceedsScreen = + width > availableSize.width || height > availableSize.height; + if (exceedsScreen) { + return null; } if (left < frameRect.left) { left = frameRect.left; @@ -1407,69 +1584,101 @@ 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) { + final isMaximized = await isWindowMaximized(); + if (isMaximized == null) { + return; + } + if (isMaximized == true) { + // setFrame may be ignored while the native window is maximized. + try { + await wc.unmaximize(); + } catch (_) { + return; + } + stateGlobal.setMaximized(false); + // Wait for the window manager and Flutter view metrics to reflect + // the restored window before calculating and setting its frame. + await Future.delayed(Duration(milliseconds: 300)); + await updateScreen(); + } + } + final mediaSize = MediaQueryData.fromView(view).size; + final frame = await _getAdjustedWindowFrame(mediaSize); + if (frame == null) { + return; + } + try { + await wc.setFrame(frame); + } catch (_) { + return; + } stateGlobal.setMaximized(false); } } updateScreen() async { - final String info = - isWeb ? screenInfo : await _getScreenInfoDesktop() ?? ''; - if (info.isEmpty) { - _screen = null; - } else { - final screenMap = jsonDecode(info); - _screen = window_size.Screen( - Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'], - screenMap['frame']['r'], screenMap['frame']['b']), - Rect.fromLTRB( - screenMap['visibleFrame']['l'], - screenMap['visibleFrame']['t'], - screenMap['visibleFrame']['r'], - screenMap['visibleFrame']['b']), - screenMap['scaleFactor']); + _screen = await _getCurrentScreen(); + } + + Future _getCurrentScreen() async { + try { + return (await window_size.getWindowInfo()).screen; + } catch (e) { + debugPrint('Failed to get current window screen: $e'); + return null; } } - _getScreenInfoDesktop() async { - final v = await rustDeskWinManager.call( - WindowType.Main, kWindowGetWindowInfo, ''); - 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; final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { return false; } - if (!isWeb) { - final remoteCount = RemoteCountState.find().value; - if (remoteCount != 1) { - return false; - } + final remoteCount = RemoteCountState.find().value; + if (remoteCount != 1) { + return false; } + await updateScreen(); 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, forMenu: true) != null; } } @@ -1478,20 +1687,11 @@ class _DisplayMenu extends StatefulWidget { final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; - final Widget pluginItem; - _DisplayMenu( - {Key? key, - required this.id, + const _DisplayMenu( + {required this.id, required this.ffi, required this.state, - required this.setFullscreen}) - : pluginItem = LocationItem.createLocationItem( - id, - ffi, - kLocationClientRemoteToolbarDisplay, - true, - ), - super(key: key); + required this.setFullscreen}); @override State<_DisplayMenu> createState() => _DisplayMenuState(); @@ -1529,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - _screenAdjustor.updateScreen(); menuChildrenGetter(_IconSubmenuButtonState state) { final menuChildren = [ _screenAdjustor.adjustWindow(context), @@ -1582,9 +1781,6 @@ class _DisplayMenuState extends State<_DisplayMenu> { ]); } } - if (ffi.connType == ConnType.defaultConn) { - menuChildren.add(widget.pluginItem); - } return menuChildren; } @@ -2096,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { Future _getLocalResolutionWayland() async { if (!isWayland) return _getLocalResolution(); - final window = await window_size.getWindowInfo(); - final screen = window.screen; - if (screen != null) { - setState(() { - _localResolution = Resolution( - screen.frame.width.toInt(), - screen.frame.height.toInt(), - ); - }); + try { + final window = await window_size.getWindowInfo(); + final screen = window.screen; + if (screen != null) { + setState(() { + _localResolution = Resolution( + screen.frame.width.toInt(), + screen.frame.height.toInt(), + ); + }); + } + } catch (e) { + debugPrint('Failed to get local resolution on Wayland: $e'); } } @@ -2176,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { return; } if (w == rect.width.toInt() && h == rect.height.toInt()) { - if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - widget.screenAdjustor.doAdjustWindow(context); + if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) { + return; + } + if (widget.screenAdjustor.isFullscreen) { + return; + } + if ((await widget.screenAdjustor.isWindowMaximized()) == false) { + // This delayed callback can outlive the menu State, so its context + // is unsafe. + widget.screenAdjustor.doAdjustWindow(); } } }); diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 9bd68ed60..5f234cb69 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart'; import 'mobile/widgets/deploy_dialog.dart'; import 'models/platform_model.dart'; -import 'package:flutter_hbb/plugin/handlers.dart' - if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart'; - /// Basic window and launch properties. int? kWindowId; WindowType? kWindowType; @@ -141,8 +138,6 @@ void runMainApp(bool startService) async { await bind.mainCheckConnectStatus(); if (startService) { gFFI.serverModel.startService(); - bind.pluginSyncUi(syncTo: kAppTypeMain); - bind.pluginListReload(); } await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]); gFFI.userModel.refreshCurrentUser(); @@ -570,12 +565,6 @@ _registerEventHandler() { reloadAllWindows(); }); } - // Register native handlers. - if (isDesktop) { - platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async { - NativeUiHandler.instance.onEvent(evt); - }); - } if (isAndroid) { platformFFI.registerEventHandler( 'android_needs_deploy', 'android_needs_deploy', (_) async { @@ -588,7 +577,8 @@ _registerEventHandler() { Widget keyListenerBuilder(BuildContext context, Widget? child) { return RawKeyboardListener( - focusNode: FocusNode(), + // `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4" + focusNode: FocusNode(skipTraversal: isWeb), child: child ?? Container(), onKey: (RawKeyEvent event) { if (event.logicalKey == LogicalKeyboardKey.shiftLeft) { diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 1e793bca7..e389bdf6c 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; @@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; +import '../../consts.dart'; class FileManagerPage extends StatefulWidget { FileManagerPage( @@ -73,6 +75,173 @@ class _FileManagerPageState extends State { DirectoryOptions get currentOptions => currentFileController.options.value; final _uniqueKey = UniqueKey(); + Future _runAndroidDocumentPicker(Future Function() action) async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + return await action(); + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + Future _importFiles() async { + var imported = 0; + var failed = false; + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final selectedFiles = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kPickImportFiles)); + if (selectedFiles == null || selectedFiles.isEmpty) return; + + for (final selected in selectedFiles) { + final uri = (selected as Map)['uri'] as String?; + final selectedName = selected['name'] as String?; + final name = selectedName?.replaceAll('\\', '/').split('/').last; + if (uri == null || + name == null || + !PathUtil.validName(name, importIsWindows)) { + failed = true; + continue; + } + final destination = + PathUtil.join(importDirectory, name, importIsWindows); + var overwrite = false; + if (await File(destination).exists()) { + final overwriteResult = await model.showFileConfirmDialog( + translate('Overwrite'), destination, false, false); + if (overwriteResult == false) break; + if (overwriteResult != true) continue; + overwrite = true; + } + try { + final success = await gFFI.invokeMethod( + AndroidChannel.kImportFile, + {'uri': uri, 'path': destination, 'overwrite': overwrite}); + if (success == true) { + imported++; + } else { + failed = true; + } + } catch (e) { + failed = true; + debugPrint('Failed to import $name: $e'); + } + } + } catch (e) { + failed = true; + debugPrint('Failed to select files for import: $e'); + } + await importController.refresh(); + if (failed) { + showToast(translate('Failed')); + } else if (imported > 0) { + showToast(translate('Successful')); + } + } + + Future _exportFile(Entry entry) async { + try { + final exported = await _runAndroidDocumentPicker(() => gFFI + .invokeMethod(AndroidChannel.kExportFile, {'path': entry.path})); + if (exported == true) { + showToast(translate('Successful')); + } + } catch (e) { + debugPrint('Failed to export ${entry.name}: $e'); + showToast(translate('Failed')); + } + } + + Future _importFolder() async { + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final picked = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kPickImportDirectory)); + if (picked == null || picked.isEmpty) return; + final uri = picked['uri'] as String?; + final name = + (picked['name'] as String?)?.replaceAll('\\', '/').split('/').last; + if (uri == null || + name == null || + name == '.' || + name == '..' || + !PathUtil.validName(name, importIsWindows)) { + showToast(translate('Failed')); + return; + } + final destination = PathUtil.join(importDirectory, name, importIsWindows); + final destinationType = await FileSystemEntity.type(destination); + var overwrite = false; + if (destinationType == FileSystemEntityType.directory) { + final overwriteResult = await model.showFileConfirmDialog( + translate('Overwrite'), destination, false, false); + if (overwriteResult != true) return; + overwrite = true; + } else if (destinationType != FileSystemEntityType.notFound) { + showToast(translate('Failed')); + return; + } + final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory, + {'uri': uri, 'path': destination, 'overwrite': overwrite}); + if (success == true) { + showToast(translate('Successful')); + } else { + showToast(translate('Failed')); + } + } catch (e) { + debugPrint('Failed to import folder: $e'); + showToast(translate('Failed')); + } + await importController.refresh(); + } + + Future _exportItems(SelectedItems items) async { + await _exportPaths(items.items.map((e) => e.path)); + } + + Future _exportLogs() async { + final home = currentFileController.homePath; + if (home.isEmpty) { + showToast(translate('Failed')); + return; + } + final appDir = PathUtil.join(home, appName, false); + final paths = [ + PathUtil.join(appDir, 'Logs', false), + PathUtil.join(appDir, 'ScreenRecord', false), + ].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList(); + if (paths.isEmpty) { + showToast(translate('Failed')); + return; + } + await _exportPaths(paths); + } + + Future _exportPaths(Iterable paths) async { + try { + final result = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + AndroidChannel.kExportFiles, {'paths': paths.toList()})); + if (result == null) return; + final exported = result['exported'] as int? ?? 0; + final failed = result['failed'] as int? ?? 0; + if (failed > 0) { + showToast(translate('Failed')); + } else if (exported > 0) { + showToast(translate('Successful')); + } + } catch (e) { + debugPrint('Failed to export paths: $e'); + showToast(translate('Failed')); + } + } + @override void initState() { super.initState(); @@ -159,6 +328,45 @@ class _FileManagerPageState extends State { ), value: "refresh", ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "import", + child: Row( + children: [ + Icon(Icons.add_to_drive, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Add")) + ], + ), + ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "import_folder", + child: Row( + children: [ + Icon(Icons.create_new_folder_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Import Folder")) + ], + ), + ), + if (isAndroid) + PopupMenuItem( + enabled: showLocal && currentDir.path.isNotEmpty, + value: "export_logs", + child: Row( + children: [ + Icon(Icons.article_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate("Export Logs")) + ], + ), + ), PopupMenuItem( enabled: currentDir.path != "/", child: Row( @@ -203,6 +411,12 @@ class _FileManagerPageState extends State { onSelected: (v) { if (v == "refresh") { currentFileController.refresh(); + } else if (v == "import") { + _importFiles(); + } else if (v == "import_folder") { + _importFolder(); + } else if (v == "export_logs") { + _exportLogs(); } else if (v == "select") { model.localController.selectedItems.clear(); model.remoteController.selectedItems.clear(); @@ -300,6 +514,24 @@ class _FileManagerPageState extends State { setState(() {}); }, actions: [ + if (isAndroid && + selectedItems?.isLocal == true && + selectedItems?.items.isNotEmpty == true) ...[ + if (selectedItems!.items.length == 1 && + selectedItems!.items.single.isFile) + IconButton( + tooltip: translate("Save as"), + icon: Icon(Icons.save_alt), + onPressed: () => + _exportFile(selectedItems!.items.single), + ) + else + IconButton( + tooltip: translate("Export"), + icon: Icon(Icons.drive_folder_upload), + onPressed: () => _exportItems(selectedItems!), + ), + ], IconButton( icon: Icon(Icons.compare_arrows), onPressed: () => setState(() => showLocal = !showLocal), @@ -366,8 +598,7 @@ class _FileManagerPageState extends State { return BottomSheetBody( leading: CircularProgressIndicator(), title: translate("Waiting"), - text: - "${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s", + text: "${readableFileSize(activeJob.speed)}/s", onCanceled: () { model.jobController.cancelJob(activeJob.id); jobTable.clear(); diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 8395f4540..f42d08a67 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1276,6 +1276,14 @@ void showOptions( List cursorToggles = await toolbarCursor(context, id, gFFI); List displayToggles = await toolbarDisplayToggle(context, id, gFFI); + if (isMobile) { + displayToggles.insert( + 0, + TToggleMenu( + child: Text(translate('Lock canvas')), + value: gFFI.canvasModel.locked, + onChanged: (value) => gFFI.canvasModel.setLocked(value == true))); + } List privacyModeList = []; if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index cd3f97a53..d61cf70b8 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -225,12 +225,6 @@ class _ServerPageState extends State { void checkService() async { gFFI.invokeMethod("check_service"); - // for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page - if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) { - AndroidPermissionManager.complete(kManageExternalStorage, - await AndroidPermissionManager.check(kManageExternalStorage)); - debugPrint("file permission finished"); - } } class ServiceNotRunningNotification extends StatelessWidget { diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index a4a76f9af..7a8c03ebb 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,8 +9,11 @@ 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_copy_shortcut.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 +71,10 @@ class _TerminalPageState extends State super.initState(); WidgetsBinding.instance.addObserver(this); + if (isWeb) { + loadLocalTerminalFontIfNeeded(); + } + debugPrint( '[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}'); @@ -184,6 +192,7 @@ class _TerminalPageState extends State KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { final hardwareKeyboard = HardwareKeyboard.instance; final shouldPaste = shouldHandleTerminalPasteShortcut( + platform: defaultTargetPlatform, logicalKey: event.logicalKey, isKeyDown: event is KeyDownEvent, isKeyRepeat: event is KeyRepeatEvent, @@ -238,7 +247,12 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, - onKeyEvent: _handleTerminalKeyEvent, + shortcuts: platformTerminalShortcuts(), + onKeyEvent: terminalCopyHandler( + _terminalModel.terminal, + _terminalModel.terminalController, + fallback: _handleTerminalKeyEvent, + ), padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 94f0fcb7b..26396bce5 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -46,6 +46,12 @@ class JobID { typedef GetSessionID = SessionID Function(); typedef GetDialogManager = OverlayDialogManager? Function(); +typedef ReadRemoteDirectory = Future Function( + SessionID sessionId, String path, bool includeHidden); + +const _kRemoteReadDirTimeout = Duration(seconds: 30); +const _kRemoteSessionChangedError = + 'Remote directory read cancelled because the session changed'; class FileModel { final WeakReference parent; @@ -84,6 +90,7 @@ class FileModel { } Future onReady() async { + fileFetcher.beginRemoteSession(); await evtLoop.onReady(); if (!isWeb) await localController.onReady(); await remoteController.onReady(); @@ -133,7 +140,11 @@ class FileModel { final id = int.tryParse(evt['id']?.toString() ?? ''); if (id != null) { final err = evt['err']?.toString() ?? 'Unknown error'; - fileFetcher.tryCompleteRecursiveTaskWithError(id, err); + if (id == 0) { + fileFetcher.tryCompleteRemoteTaskWithError(err); + } else { + fileFetcher.tryCompleteRecursiveTaskWithError(id, err); + } } // Always call jobController.jobError(evt) to ensure all error events are processed, // even if the event does not have a valid job ID. This allows for generic error handling @@ -350,6 +361,8 @@ class FileController { final history = RxList.empty(growable: true); final sortBy = SortBy.name.obs; var sortAscending = true; + // Incremented for each navigation; only the latest generation applies results. + int _directoryRequestGeneration = 0; final JobController jobController; final WeakReference rootState; @@ -368,6 +381,14 @@ class FileController { void set homePath(String path) => options.value.home = path; OverlayDialogManager? get dialogManager => rootState.target?.dialogManager; + bool _isPathAllowed(String candidate) { + if (!isAndroid || !isLocal) return true; + if (homePath.isEmpty || candidate.isEmpty) return false; + final home = PathUtil.posixContext.normalize(homePath); + final target = PathUtil.posixContext.normalize(candidate); + return target == home || PathUtil.posixContext.isWithin(home, target); + } + String get shortPath { final dirPath = directory.value.path; if (dirPath.startsWith(homePath)) { @@ -401,8 +422,13 @@ class FileController { await Future.delayed(Duration(milliseconds: 100)); - final savedDir = (await bind.sessionGetPeerOption( + var savedDir = (await bind.sessionGetPeerOption( sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); + if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) { + savedDir = options.value.home; + await bind.sessionPeerOption( + sessionId: sessionId, name: "local_dir", value: savedDir); + } Future tryOpenReadyDirs() async { final dirs = { if (directory.value.path.isNotEmpty) directory.value.path, @@ -472,6 +498,9 @@ class FileController { } Future _openDirectoryPath(String path, {bool isBack = false}) async { + if (!_isPathAllowed(path)) { + return false; + } if (!isBack) { pushHistory(); } @@ -484,12 +513,20 @@ class FileController { path = "$path\\"; } } + final requestGeneration = ++_directoryRequestGeneration; try { final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); + if (requestGeneration != _directoryRequestGeneration) { + return true; + } fd.format(isWindows, sort: sortBy.value); + selectedItems.reconcile(fd.entries); directory.value = fd; return true; } catch (e) { + if (requestGeneration != _directoryRequestGeneration) { + return true; + } debugPrint("Failed to openDirectory $path: $e"); return false; } @@ -530,6 +567,9 @@ class FileController { final isWindows = options.value.isWindows; final dirPath = directory.value.path; var parent = PathUtil.dirname(dirPath, isWindows); + if (!_isPathAllowed(parent)) { + return true; + } // specially for C:\, D:\, goto '/' if (parent == dirPath && isWindows) { return await _openDirectoryPath('/', isBack: isBack); @@ -541,6 +581,7 @@ class FileController { void initDirAndHome(Map evt) { try { final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); + final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path); fd.format(options.value.isWindows, sort: sortBy.value); if (fd.id > 0) { final jobIndex = jobController.getJob(fd.id); @@ -556,10 +597,12 @@ class FileController { debugPrint("update receive details: ${fd.path}"); jobController.jobTable.refresh(); } - } else if (options.value.home.isEmpty) { + } else if (options.value.home.isEmpty && isHomeResponse) { options.value.home = fd.path; debugPrint("init remote home: ${fd.path}"); - directory.value = fd; + if (_directoryRequestGeneration == 0) { + directory.value = fd; + } } } catch (e) { debugPrint("initDirAndHome err=$e"); @@ -1362,16 +1405,78 @@ class JobResultListener { } } +class _RemoteReadTask { + final bool includeHidden; + final Completer completer = Completer(); + final Completer released = Completer(); + late final Timer timer; + + _RemoteReadTask(this.includeHidden); +} + class FileFetcher { // Map> localTasks = {}; // now we only use read local dir sync - Map> remoteTasks = {}; + final Map _remoteReadTasks = {}; Map>> remoteEmptyDirsTasks = {}; Map> readRecursiveTasks = {}; + int _remoteSessionGeneration = 0; final GetSessionID getSessionID; + final ReadRemoteDirectory _readRemoteDirectory; SessionID get sessionId => getSessionID(); - FileFetcher(this.getSessionID); + FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory}) + : _readRemoteDirectory = readRemoteDirectory ?? + ((sessionId, path, includeHidden) => bind.sessionReadRemoteDir( + sessionId: sessionId, + path: path, + includeHidden: includeHidden)); + + bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path); + + bool isLikelyRemoteHomeResponse(String path) => + _remoteReadTasks.isEmpty || + (_remoteReadTasks.length == 1 && + hasPendingRemoteRead("") && + !hasPendingRemoteRead(path)); + + void beginRemoteSession() { + _remoteSessionGeneration++; + final pendingTasks = _remoteReadTasks.entries.toList(growable: false); + for (final entry in pendingTasks) { + final task = entry.value; + if (!_removeRemoteReadTask(entry.key, task)) continue; + task.completer.completeError(StateError(_kRemoteSessionChangedError)); + } + } + + _RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) { + if (hasPendingRemoteRead(path)) { + throw "Failed to registerReadTask, already have same read job"; + } + final task = _RemoteReadTask(includeHidden); + _remoteReadTasks[path] = task; + task.timer = Timer(_kRemoteReadDirTimeout, () { + if (!_removeRemoteReadTask(path, task)) return; + task.completer.completeError("Failed to read dir, timeout"); + }); + return task; + } + + bool _removeRemoteReadTask(String path, _RemoteReadTask task) { + if (!identical(_remoteReadTasks[path], task)) return false; + _remoteReadTasks.remove(path); + task.timer.cancel(); + task.released.complete(); + return true; + } + + bool _completeRemoteReadTask(String path, FileDirectory directory) { + final task = _remoteReadTasks[path]; + if (task == null || !_removeRemoteReadTask(path, task)) return false; + task.completer.complete(directory); + return true; + } Future> registerReadEmptyDirsTask( bool isLocal, String path) { @@ -1391,23 +1496,6 @@ class FileFetcher { return c.future; } - Future registerReadTask(bool isLocal, String path) { - // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later - final tasks = remoteTasks; // bypass now - if (tasks.containsKey(path)) { - throw "Failed to registerReadTask, already have same read job"; - } - final c = Completer(); - tasks[path] = c; - - Timer(Duration(seconds: 2), () { - tasks.remove(path); - if (c.isCompleted) return; - c.completeError("Failed to read dir, timeout"); - }); - return c.future; - } - Future registerReadRecursiveTask(int actID) { final tasks = readRecursiveTasks; if (tasks.containsKey(actID)) { @@ -1445,27 +1533,37 @@ class FileFetcher { tryCompleteTask(String? msg, String? isLocalStr) { if (msg == null || isLocalStr == null) return; - late final Map> tasks; try { final fd = FileDirectory.fromJson(jsonDecode(msg)); if (fd.id > 0) { // fd.id > 0 is result for read recursive - // to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read - tasks = readRecursiveTasks; - final completer = tasks.remove(fd.id); - completer?.complete(fd); - } else if (fd.path.isNotEmpty) { - // result for normal read dir - // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later - tasks = remoteTasks; // bypass now - final completer = tasks.remove(fd.path); + final completer = readRecursiveTasks.remove(fd.id); completer?.complete(fd); + return; + } + if (isLocalStr == "false" && fd.path.isNotEmpty) { + if (_completeRemoteReadTask(fd.path, fd)) { + return; + } + // A Home request uses an empty path but returns its resolved path. + if (isLikelyRemoteHomeResponse(fd.path)) { + _completeRemoteReadTask("", fd); + } } } catch (e) { debugPrint("tryCompleteJob err: $e"); } } + bool tryCompleteRemoteTaskWithError(String error) { + if (_remoteReadTasks.length != 1) return false; + final entry = _remoteReadTasks.entries.single; + final task = entry.value; + if (!_removeRemoteReadTask(entry.key, task)) return false; + task.completer.completeError(error); + return true; + } + // Complete a pending recursive read task with an error. // See FileModel.handleJobError() for why this is necessary. void tryCompleteRecursiveTaskWithError(int id, String error) { @@ -1506,9 +1604,26 @@ class FileFetcher { final fd = FileDirectory.fromJson(jsonDecode(res)); return fd; } else { - await bind.sessionReadRemoteDir( - sessionId: sessionId, path: path, includeHidden: showHidden); - return registerReadTask(isLocal, path); + final remoteSessionGeneration = _remoteSessionGeneration; + final pendingTask = _remoteReadTasks[path]; + if (pendingTask != null) { + if (pendingTask.includeHidden == showHidden) { + return pendingTask.completer.future; + } + await pendingTask.released.future; + if (remoteSessionGeneration != _remoteSessionGeneration) { + throw StateError(_kRemoteSessionChangedError); + } + return fetchDirectory(path, isLocal, showHidden); + } + final task = _registerRemoteReadTask(path, showHidden); + unawaited(Future.sync( + () => _readRemoteDirectory(sessionId, path, showHidden)) + .catchError((Object error, StackTrace stackTrace) { + if (!_removeRemoteReadTask(path, task)) return; + task.completer.completeError(error, stackTrace); + })); + return task.completer.future; } } catch (e) { return Future.error(e); @@ -1790,7 +1905,7 @@ class PathUtil { } static bool validName(String name, bool isWindows) { - final unixFileNamePattern = RegExp(r'^[^/\0]+$'); + final unixFileNamePattern = RegExp(r'^[^/\x00]+$'); final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$'); final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern; return reg.hasMatch(name); @@ -1833,6 +1948,21 @@ class SelectedItems { items.clear(); } + void reconcile(List entries) { + if (items.isEmpty) return; + final currentByPath = {for (final entry in entries) entry.path: entry}; + final reconciled = []; + for (final item in items) { + final current = currentByPath[item.path]; + if (current != null && current.entryType == item.entryType) { + reconciled.add(current); + } + } + items + ..clear() + ..addAll(reconciled); + } + void selectAll(List entries) { items.clear(); items.addAll(entries); diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index a701e6e53..6ea23c2c9 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -1787,6 +1787,11 @@ class InputModel { } bool _checkPeerControlProtected(double x, double y) { + if (isViewOnly && showMyCursor) { + lastMousePos = ui.Offset(x, y); + return false; + } + final cursorModel = parent.target!.cursorModel; if (cursorModel.isPeerControlProtected) { lastMousePos = ui.Offset(x, y); diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index 9b8aae881..093e65776 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -117,10 +117,11 @@ String prepareTerminalInputPayload( /// 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. +/// xterm already handles each platform's paste shortcut 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 off. bool shouldHandleTerminalPasteShortcut({ + required TargetPlatform platform, required LogicalKeyboardKey logicalKey, required bool isKeyDown, required bool isKeyRepeat, @@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({ if (!modifierLockActive) return false; if (!isKeyDown && !isKeyRepeat) return false; if (logicalKey != LogicalKeyboardKey.keyV) return false; - if (altPressed || shiftPressed) return false; - return controlPressed != metaPressed; + if (altPressed) return false; + switch (platform) { + case TargetPlatform.linux: + return controlPressed && !metaPressed && shiftPressed; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return !controlPressed && metaPressed && !shiftPressed; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + return controlPressed && !metaPressed && !shiftPressed; + } } /// Returns true when collapsing Row3 should also clear hidden modifier state. diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 175e3ff2d..e22782034 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; @@ -127,6 +124,8 @@ class FfiModel with ChangeNotifier { Timer? _restartReconnectDelayTimer; var _reconnects = 1; DateTime? _offlineReconnectStartTime; + bool _androidDocumentPickerActive = false; + bool _androidDocumentPickerInterruptedConnection = false; bool _viewOnly = false; bool _showMyCursor = false; WeakReference parent; @@ -258,6 +257,8 @@ class FfiModel with ChangeNotifier { _inputBlocked = false; _timer?.cancel(); _timer = null; + _androidDocumentPickerActive = false; + _androidDocumentPickerInterruptedConnection = false; resetRestartReconnectState(); clearPermissions(); waitForImageTimer?.cancel(); @@ -437,15 +438,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']; @@ -904,6 +896,13 @@ class FfiModel with ChangeNotifier { final text = evt['text']; final link = evt['link']; + if (isAndroid && + _androidDocumentPickerActive && + title == 'Connection Error') { + _androidDocumentPickerInterruptedConnection = true; + return; + } + // Disable relative mouse mode on any error-type message to ensure cursor is released. // This includes connection errors, session-ending messages, elevation errors, etc. // Safety: releasing pointer lock on errors prevents the user from being stuck. @@ -920,17 +919,12 @@ class FfiModel with ChangeNotifier { enter2FaDialog(sessionId, dialogManager); } else if (type == 'input-password') { enterPasswordDialog(sessionId, dialogManager); - } else if (type == 'session-login' || type == 'session-re-login') { - enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true); - } else if (type == 'session-login-password') { - enterUserLoginAndPasswordDialog( - sessionId, dialogManager, 'login_linux_tip', true); } else if (type == 'terminal-admin-login') { enterUserLoginDialog( - sessionId, dialogManager, 'terminal-admin-login-tip', false); + sessionId, dialogManager, 'terminal-admin-login-tip'); } else if (type == 'terminal-admin-login-password') { enterUserLoginAndPasswordDialog( - sessionId, dialogManager, 'terminal-admin-login-tip', false); + sessionId, dialogManager, 'terminal-admin-login-tip'); } else if (type == 'restarting') { // Treat restart messages as reconnect control events. Rust still sends // title/text for legacy UI and translation reuse; Flutter keeps the last @@ -985,6 +979,23 @@ class FfiModel with ChangeNotifier { _restartReconnectDelayTimer = null; } + void beginAndroidDocumentPicker() { + if (!isAndroid) return; + _androidDocumentPickerActive = true; + _androidDocumentPickerInterruptedConnection = false; + } + + void endAndroidDocumentPicker() { + if (!isAndroid) return; + _androidDocumentPickerActive = false; + if (!_androidDocumentPickerInterruptedConnection || + parent.target?.closed == true) { + return; + } + _androidDocumentPickerInterruptedConnection = false; + reconnect(parent.target!.dialogManager, sessionId, false); + } + /// Auto-retry check for "Remote desktop is offline" error. /// returns true to auto-retry, false otherwise. bool shouldAutoRetryOnOffline( @@ -1952,6 +1963,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 +1980,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 +2000,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; @@ -2206,6 +2236,7 @@ class CanvasModel with ChangeNotifier { double _y = 0; // image scale double _scale = 1.0; + bool _locked = false; double _devicePixelRatio = 1.0; Size _size = Size.zero; // the tabbar over the image @@ -2254,12 +2285,19 @@ class CanvasModel with ChangeNotifier { double get x => _x; double get y => _y; double get scale => _scale; + bool get locked => _locked; double get devicePixelRatio => _devicePixelRatio; Size get size => _size; ScrollStyle get scrollStyle => _scrollStyle; ViewStyle get viewStyle => _lastViewStyle; RxBool get imageOverflow => _imageOverflow; + void setLocked(bool value) { + if (_locked == value) return; + _locked = value; + notifyListeners(); + } + _resetScroll() => setScrollPercent(0.0, 0.0); void setScrollPercent(double x, double y) { @@ -2488,6 +2526,7 @@ class CanvasModel with ChangeNotifier { } void updateLocalCursor(double x, double y) { + if (parent.target?.ffiModel.viewOnly == true) return; // If keyboard is not permitted, do not move cursor when mouse is moving. if (parent.target != null && parent.target!.ffiModel.keyboard) { // Draw cursor if is not desktop. @@ -2720,6 +2759,7 @@ class CanvasModel with ChangeNotifier { _x = 0; _y = 0; _scale = 1.0; + _locked = false; _lastViewStyle = ViewStyle.defaultViewStyle(); _timerMobileFocusCanvasCursor?.cancel(); _timerMobileRestoreCanvasOffset?.cancel(); @@ -2831,7 +2871,7 @@ class CursorData { required this.width, required this.height, }) : hotx = hotxOrigin * scale, - hoty = hotxOrigin * scale; + hoty = hotyOrigin * scale; int _doubleToInt(double v) => (v * 10e6).round().toInt(); @@ -3853,6 +3893,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 +3989,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 +4045,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) { @@ -4036,6 +4088,11 @@ class FFI { return await platformFFI.invokeMethod(method, arguments); } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return await platformFFI.invokeMethodWithResult(method, arguments); + } + // Terminal model management void registerTerminalModel(int terminalId, TerminalModel model) { debugPrint('[FFI] Registering terminal model for terminal $terminalId'); @@ -4140,7 +4197,6 @@ class PeerInfo with ChangeNotifier { RxBool isSet = false.obs; bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true; - bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true; bool get isInstalled => platform != kPeerPlatformWindows || platformAdditions[kPlatformAdditionsIsInstalled] == true; diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index e73cbc0cb..93f06d55d 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -1,9 +1,9 @@ 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'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -170,8 +170,10 @@ class PlatformFFI { _startListenEvent(_ffiBind); // global event try { if (isAndroid) { - // only support for android - _homeDir = (await ExternalPath.getExternalStorageDirectories())[0]; + // Android file transfer uses app-specific storage. User-selected + // files enter and leave this workspace through the system picker. + _homeDir = (await getExternalStorageDirectory())?.path ?? + (await getApplicationSupportDirectory()).path; } else if (isIOS) { // The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`, // which provided the `downloads` path in the sandbox. @@ -283,6 +285,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() {} @@ -299,6 +307,12 @@ class PlatformFFI { return await _toAndroidChannel.invokeMethod(method, arguments); } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + if (!isAndroid) return null; + return await _toAndroidChannel.invokeMethod(method, arguments); + } + void syncAndroidServiceAppDirConfigPath() { invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); } diff --git a/flutter/lib/models/rustdesk_terminal.dart b/flutter/lib/models/rustdesk_terminal.dart new file mode 100644 index 000000000..6e3f35dfd --- /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/server_model.dart b/flutter/lib/models/server_model.dart index 40c94fcf5..031a62509 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier { _audioOk = audioOption != 'N'; } - // file - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - _fileOk = false; - bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N"); - } else { - final fileOption = - await bind.mainGetOption(key: kOptionEnableFileTransfer); - _fileOk = fileOption != 'N'; - } + // Android file transfer is confined to app-specific storage. Files enter + // and leave the workspace through Android's system document picker. + final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer); + _fileOk = fileOption != 'N'; // clipboard final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard); @@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier { if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } - if (!_fileOk && - !await AndroidPermissionManager.check(kManageExternalStorage)) { - final res = - await AndroidPermissionManager.request(kManageExternalStorage); - if (!res) { - showToast(translate('Failed')); - return; - } - } - _fileOk = !_fileOk; bind.mainSetOption( key: kOptionEnableFileTransfer, @@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier { if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') { await checkFloatingWindowPermission(); } - if (!await AndroidPermissionManager.check(kManageExternalStorage)) { - await AndroidPermissionManager.request(kManageExternalStorage); - } final res = await parent.target?.dialogManager .show((setState, close, context) { submit() => close(true); @@ -738,9 +720,13 @@ class ServerModel with ChangeNotifier { } } - Future closeAll() async { - await Future.wait( - _clients.map((client) => bind.cmCloseConnection(connId: client.id))); + /// `byOperator` false means the CM's window went away rather than a person asking for the + /// peers to go. The sessions end either way; only the close reason differs, and with it + /// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`. + Future closeAll({bool byOperator = true}) async { + await Future.wait(_clients.map((client) => byOperator + ? bind.cmCloseConnection(connId: client.id) + : bind.cmCloseConnectionWindow(connId: client.id))); _clients.clear(); tabController.state.value.tabs.clear(); if (isAndroid) androidUpdatekeepScreenOn(); diff --git a/flutter/lib/models/terminal_copy_shortcut.dart b/flutter/lib/models/terminal_copy_shortcut.dart new file mode 100644 index 000000000..242586e6b --- /dev/null +++ b/flutter/lib/models/terminal_copy_shortcut.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +const _controlShiftVPasteShortcut = SingleActivator( + LogicalKeyboardKey.keyV, + control: true, + shift: true, +); + +Future writeTerminalClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + } catch (error) { + debugPrint('[Terminal] Failed to write clipboard: $error'); + } +} + +Map? platformTerminalShortcuts() { + final platform = defaultTargetPlatform; + if (platform == TargetPlatform.linux) { + return { + for (final entry in defaultTerminalShortcuts.entries) + if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV)) + entry.key: entry.value, + _controlShiftVPasteShortcut: + const PasteTextIntent(SelectionChangedCause.keyboard), + }; + } + if (platform != TargetPlatform.windows && + platform != TargetPlatform.android) { + return null; + } + return { + for (final entry in defaultTerminalShortcuts.entries) + if (!_isControlShortcut( + entry.key, + LogicalKeyboardKey.keyC, + shift: true, + )) + entry.key: entry.value, + }; +} + +bool _isControlShortcut( + ShortcutActivator shortcut, + LogicalKeyboardKey key, { + bool shift = false, +}) => + shortcut is SingleActivator && + shortcut.trigger == key && + shortcut.control && + shortcut.shift == shift && + !shortcut.alt && + !shortcut.meta; + +FocusOnKeyEventCallback terminalCopyHandler( + Terminal terminal, + TerminalController controller, { + FocusOnKeyEventCallback? fallback, +}) => + (focusNode, event) { + if (_isSelectionCopyShortcut(event)) { + final selection = controller.selection; + if (selection != null && !selection.isCollapsed) { + if (event is KeyDownEvent) { + final text = terminal.buffer.getText(selection); + unawaited(writeTerminalClipboard(text)); + } + return KeyEventResult.handled; + } + } + return fallback?.call(focusNode, event) ?? KeyEventResult.ignored; + }; + +bool _isSelectionCopyShortcut(KeyEvent event) { + final keyboard = HardwareKeyboard.instance; + final platform = defaultTargetPlatform; + final usesControlCopy = + platform == TargetPlatform.windows || platform == TargetPlatform.android; + return usesControlCopy && + (event is KeyDownEvent || event is KeyRepeatEvent) && + event.logicalKey == LogicalKeyboardKey.keyC && + keyboard.isControlPressed && + !keyboard.isShiftPressed && + !keyboard.isAltPressed && + !keyboard.isMetaPressed; +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 6f179afe2..63e831202 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -10,6 +10,8 @@ import 'package:xterm/xterm.dart'; import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; +import 'rustdesk_terminal.dart'; +import 'terminal_mouse_handler.dart'; class TerminalModel with ChangeNotifier { final String id; // peer id @@ -128,7 +130,8 @@ class TerminalModel with ChangeNotifier { } TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { - terminal = Terminal(maxLines: 10000); + terminal = RustDeskTerminal(maxLines: 10000); + terminal.mouseHandler = const WheelButtonFixMouseHandler(); terminalController = TerminalController(); // Setup terminal callbacks diff --git a/flutter/lib/models/terminal_mouse_drag_reporter.dart b/flutter/lib/models/terminal_mouse_drag_reporter.dart new file mode 100644 index 000000000..d08fc9afd --- /dev/null +++ b/flutter/lib/models/terminal_mouse_drag_reporter.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:xterm/xterm.dart'; + +const _cellIndexOffset = 1; +const _legacyCodeOffset = 32; +const _leftButtonCode = 0; +const _motionButtonCode = 32; +const _releaseButtonCode = 3; +const _shiftModifierCode = 4; +const _metaModifierCode = 8; +const _controlModifierCode = 16; +const _modifierCodeMask = + _shiftModifierCode | _metaModifierCode | _controlModifierCode; +const _normalCoordinateLimit = 223; +const _utfCoordinateLimit = 2015; + +String encodeTerminalMouseReport( + MouseReportMode mode, + int button, + CellOffset position, { + bool release = false, +}) { + final x = position.x + _cellIndexOffset; + final y = position.y + _cellIndexOffset; + final reportedButton = + release ? _releaseButtonCode | (button & _modifierCodeMask) : button; + switch (mode) { + case MouseReportMode.normal: + case MouseReportMode.utf: + final limit = mode == MouseReportMode.normal + ? _normalCoordinateLimit + : _utfCoordinateLimit; + final encodedButton = + String.fromCharCode(_legacyCodeOffset + reportedButton); + return '\x1b[M$encodedButton${_legacyCoordinate(x, limit)}' + '${_legacyCoordinate(y, limit)}'; + case MouseReportMode.sgr: + final suffix = release ? 'm' : 'M'; + return '\x1b[<$button;$x;$y$suffix'; + case MouseReportMode.urxvt: + return '\x1b[${_legacyCodeOffset + reportedButton};$x;${y}M'; + } +} + +String _legacyCoordinate(int value, int limit) => + value > limit ? '\x00' : String.fromCharCode(_legacyCodeOffset + value); + +int _activeModifierCode() { + final keyboard = HardwareKeyboard.instance; + return (keyboard.isShiftPressed ? _shiftModifierCode : 0) | + (keyboard.isAltPressed ? _metaModifierCode : 0) | + (keyboard.isControlPressed ? _controlModifierCode : 0); +} + +class TerminalMouseDragReporter { + int? _pointerId; + TerminalController? _controller; + late CellOffset _lastReportedPosition; + var _ownsControllerSuspension = false; + var _releasePending = false; + var _reporting = false; + + bool handleDown( + PointerDownEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) { + return false; + } + if (terminalView == null || terminalView.widget.readOnly) return false; + final controller = terminalView.widget.controller; + if (controller == null || + controller.suspendedPointerInputs || + !controller.pointerInput.inputs.contains(PointerInput.tap)) { + return false; + } + + cancel(); + _pointerId = event.pointer; + _controller = controller; + _ownsControllerSuspension = true; + _releasePending = true; + _reporting = true; + controller.setSuspendPointerInput(true); + _clearSelection(controller); + final position = _cellAt(event, terminalView); + _lastReportedPosition = position; + terminal.textInput( + _report(terminal.mouseReportMode, position), + ); + return true; + } + + bool handleMove( + PointerMoveEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (event.pointer != _pointerId) return false; + if (terminalView == null) { + cancel(); + return true; + } + final reportsDrag = _reportsDrag(terminal.mouseMode); + if (!_isPrimaryMouse(event)) { + if (_releasePending && reportsDrag) { + _reportRelease( + terminal, + _reporting ? _cellAt(event, terminalView) : _lastReportedPosition, + ); + } + cancel(); + return true; + } + if (!_reporting || !reportsDrag) { + if (!reportsDrag) _releasePending = false; + _reporting = false; + // Keep ownership until the matching end event to suppress local selection. + final controller = _controller; + scheduleMicrotask(() => _clearSelection(controller)); + return true; + } + + final position = _cellAt(event, terminalView); + _lastReportedPosition = position; + terminal.textInput( + _report(terminal.mouseReportMode, position, motion: true), + ); + final controller = _controller; + scheduleMicrotask(() => _clearSelection(controller)); + return true; + } + + bool handleEnd( + PointerEvent event, + Terminal terminal, + TerminalViewState? terminalView, + ) { + if (event.pointer != _pointerId) return false; + if (terminalView != null && + _releasePending && + _reportsDrag(terminal.mouseMode)) { + _reportRelease( + terminal, + _reporting ? _cellAt(event, terminalView) : _lastReportedPosition, + ); + } + _clearSelection(_controller); + final controller = _controller; + _pointerId = null; + // Keep xterm's tap recognizer suspended for this pointer event. + scheduleMicrotask(() { + if (_pointerId == null && identical(_controller, controller)) { + _clearSelection(controller); + cancel(); + } + }); + return true; + } + + void cancel() { + final controller = _controller; + if (_ownsControllerSuspension) { + controller?.setSuspendPointerInput(false); + } + _pointerId = null; + _controller = null; + _ownsControllerSuspension = false; + _releasePending = false; + _reporting = false; + } + + void updateController(TerminalController controller) { + final oldController = _controller; + if (_pointerId == null || oldController == null) { + cancel(); + return; + } + if (identical(oldController, controller)) return; + if (_ownsControllerSuspension) { + oldController.setSuspendPointerInput(false); + } + final acceptsPointerInput = !controller.suspendedPointerInputs && + controller.pointerInput.inputs.contains(PointerInput.tap); + _controller = controller; + _ownsControllerSuspension = acceptsPointerInput; + _reporting = _reporting && acceptsPointerInput; + if (_ownsControllerSuspension) controller.setSuspendPointerInput(true); + _clearSelection(controller); + } + + void _reportRelease(Terminal terminal, CellOffset position) { + terminal.textInput( + _report( + terminal.mouseReportMode, + position, + release: true, + ), + ); + } + + CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) { + final renderTerminal = terminalView.renderTerminal; + return renderTerminal.getCellOffset( + renderTerminal.globalToLocal(event.position), + ); + } + + bool _isPrimaryMouse(PointerEvent event) => + event.kind == PointerDeviceKind.mouse && + (event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton; + + bool _reportsDrag(MouseMode mode) => + mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove; + + void _clearSelection(TerminalController? controller) { + if (controller == null || controller.selection == null) return; + controller.clearSelection(); + } + + String _report( + MouseReportMode mode, + CellOffset position, { + bool release = false, + bool motion = false, + }) { + final baseButton = motion ? _motionButtonCode : _leftButtonCode; + final button = baseButton | _activeModifierCode(); + return encodeTerminalMouseReport(mode, button, position, release: release); + } +} diff --git a/flutter/lib/models/terminal_mouse_handler.dart b/flutter/lib/models/terminal_mouse_handler.dart new file mode 100644 index 000000000..76d84a2d4 --- /dev/null +++ b/flutter/lib/models/terminal_mouse_handler.dart @@ -0,0 +1,301 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +import 'terminal_copy_shortcut.dart'; +import 'terminal_mouse_drag_reporter.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({this.positionProvider}); + + final CellOffset? Function()? positionProvider; + + @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 position = positionProvider?.call() ?? event.position; + return encodeTerminalMouseReport( + event.state.mouseReportMode, + button, + position, + ); + } +} + +class TerminalMouseInteraction extends StatefulWidget { + const TerminalMouseInteraction( + this.terminal, { + super.key, + required this.controller, + this.focusNode, + this.backgroundOpacity = 1, + this.padding, + this.onSecondaryTapDown, + }); + + final Terminal terminal; + final TerminalController controller; + final FocusNode? focusNode; + final double backgroundOpacity; + final EdgeInsets? padding; + final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown; + + @override + State createState() => + _TerminalMouseInteractionState(); +} + +class _TerminalMouseInteractionState extends State { + static const _selectionScrollInterval = Duration(milliseconds: 50); + static const _noScroll = 0; + static const _scrollUp = -1; + static const _scrollDown = 1; + + final _terminalViewKey = GlobalKey(); + final _scrollController = ScrollController(); + final _mouseDrag = TerminalMouseDragReporter(); + late final WheelButtonFixMouseHandler _mouseHandler; + TerminalMouseHandler? _previousMouseHandler; + Offset? _pointerPosition; + Offset? _selectionPointer; + CellAnchor? _selectionBase; + Buffer? _selectionBuffer; + int? _selectionPointerId; + Timer? _selectionScrollTimer; + var _selectionHasScrolled = false; + var _scrollDirection = _noScroll; + TerminalViewState? get _terminalView => _terminalViewKey.currentState; + + @override + void initState() { + super.initState(); + _mouseHandler = WheelButtonFixMouseHandler( + positionProvider: _cellAtPointer, + ); + _installMouseHandler(widget.terminal); + } + + @override + void didUpdateWidget(TerminalMouseInteraction oldWidget) { + super.didUpdateWidget(oldWidget); + final terminalChanged = !identical(oldWidget.terminal, widget.terminal); + final controllerChanged = + !identical(oldWidget.controller, widget.controller); + if (!terminalChanged && !controllerChanged) return; + if (controllerChanged && !terminalChanged) { + _mouseDrag.updateController(widget.controller); + } else { + _mouseDrag.cancel(); + } + _clearSelectionDrag(); + if (!terminalChanged) return; + _restoreMouseHandler(oldWidget.terminal); + _installMouseHandler(widget.terminal); + } + + void _installMouseHandler(Terminal terminal) { + _previousMouseHandler = terminal.mouseHandler; + terminal.mouseHandler = _mouseHandler; + } + + void _restoreMouseHandler(Terminal terminal) { + if (identical(terminal.mouseHandler, _mouseHandler)) { + terminal.mouseHandler = _previousMouseHandler; + } + } + + CellOffset? _cellAtPointer() { + final terminalView = _terminalView; + final pointerPosition = _pointerPosition; + if (terminalView == null || pointerPosition == null) return null; + final renderTerminal = terminalView.renderTerminal; + return renderTerminal.getCellOffset( + renderTerminal.globalToLocal(pointerPosition), + ); + } + + void _updatePointerPosition(PointerEvent event) => + _pointerPosition = event.position; + + void _handlePointerDown(PointerDownEvent event) { + _updatePointerPosition(event); + if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) { + _clearSelectionDrag(); + return; + } + if (event.kind != PointerDeviceKind.mouse || + (event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) { + return; + } + _clearSelectionDrag(); + final terminalView = _terminalView; + if (terminalView == null) return; + final renderTerminal = terminalView.renderTerminal; + final localPosition = renderTerminal.globalToLocal(event.position); + final selectionBuffer = widget.terminal.buffer; + _selectionPointerId = event.pointer; + _selectionBase = selectionBuffer.createAnchorFromOffset( + renderTerminal.getCellOffset(localPosition), + ); + _selectionBuffer = selectionBuffer; + _selectionPointer = localPosition; + } + + void _handlePointerMove(PointerMoveEvent event) { + _updatePointerPosition(event); + if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return; + if (event.pointer != _selectionPointerId) return; + if (event.kind != PointerDeviceKind.mouse || + (event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) { + _clearSelectionDrag(); + return; + } + final terminalView = _terminalView; + if (terminalView == null || _selectionBase == null) return; + final renderTerminal = terminalView.renderTerminal; + final localPosition = renderTerminal.globalToLocal(event.position); + _selectionPointer = localPosition; + _setScrollDirection( + _directionFor(localPosition, renderTerminal.paintBounds), + ); + if (_selectionHasScrolled) { + scheduleMicrotask(() => _scrollSelection(scroll: false)); + } + } + + int _directionFor(Offset position, Rect bounds) { + if (position.dy < bounds.top) return _scrollUp; + if (position.dy >= bounds.bottom) return _scrollDown; + return _noScroll; + } + + void _setScrollDirection(int direction) { + if (_scrollDirection == direction) return; + _stopAutoScroll(); + _scrollDirection = direction; + if (direction == _noScroll) return; + _scrollSelection(); + if (_scrollDirection != _noScroll) { + _selectionScrollTimer = Timer.periodic( + _selectionScrollInterval, + (_) => _scrollSelection(), + ); + } + } + + void _scrollSelection({bool scroll = true}) { + final terminalView = _terminalView; + final selectionBase = _selectionBase; + final selectionBuffer = _selectionBuffer; + final selectionPointer = _selectionPointer; + if (terminalView == null || + selectionBase == null || + selectionBuffer == null || + selectionPointer == null || + !_scrollController.hasClients) { + return; + } + if (!identical(selectionBuffer, widget.terminal.buffer) || + !selectionBase.attached) { + _clearSelectionDrag(); + return; + } + final renderTerminal = terminalView.renderTerminal; + if (scroll) { + final position = _scrollController.position; + final target = + (position.pixels + renderTerminal.lineHeight * _scrollDirection) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target == position.pixels) { + _stopAutoScroll(); + } else { + position.jumpTo(target); + _selectionHasScrolled = true; + } + } + renderTerminal.selectCharacters( + renderTerminal.getOffset(selectionBase.offset), + selectionPointer, + ); + } + + void _handlePointerEnd(PointerEvent event) { + _updatePointerPosition(event); + if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) && + event.pointer != _selectionPointerId) return; + if (_selectionHasScrolled) _scrollSelection(scroll: false); + _clearSelectionDrag(); + } + + void _clearSelectionDrag() { + _selectionPointerId = null; + _selectionBase?.dispose(); + _selectionBase = null; + _selectionBuffer = null; + _selectionPointer = null; + _selectionHasScrolled = false; + _stopAutoScroll(); + } + + void _stopAutoScroll() { + _selectionScrollTimer?.cancel(); + _selectionScrollTimer = null; + _scrollDirection = _noScroll; + } + + @override + void dispose() { + _mouseDrag.cancel(); + _clearSelectionDrag(); + _restoreMouseHandler(widget.terminal); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerEnd, + onPointerHover: _updatePointerPosition, + onPointerCancel: _handlePointerEnd, + onPointerSignal: _updatePointerPosition, + onPointerPanZoomStart: _updatePointerPosition, + onPointerPanZoomUpdate: _updatePointerPosition, + onPointerPanZoomEnd: _updatePointerPosition, + child: TerminalView( + widget.terminal, + key: _terminalViewKey, + controller: widget.controller, + scrollController: _scrollController, + focusNode: widget.focusNode, + backgroundOpacity: widget.backgroundOpacity, + padding: widget.padding, + shortcuts: platformTerminalShortcuts(), + onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller), + onSecondaryTapDown: widget.onSecondaryTapDown, + ), + ); + } +} diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index 5241c3974..be8d83500 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())); @@ -184,6 +251,11 @@ class PlatformFFI { return true; } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return null; + } + // just for compilation void syncAndroidServiceAppDirConfigPath() {} 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 000000000..b78b69166 --- /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/native/common.dart b/flutter/lib/native/common.dart index 96d5bd6e8..1e76c70c5 100644 --- a/flutter/lib/native/common.dart +++ b/flutter/lib/native/common.dart @@ -10,8 +10,6 @@ final isWebDesktop_ = false; final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux; -String get screenInfo_ => ''; - final isWebOnWindows_ = false; final isWebOnLinux_ = false; final isWebOnMacOS_ = false; diff --git a/flutter/lib/plugin/common.dart b/flutter/lib/plugin/common.dart deleted file mode 100644 index d984c68ea..000000000 --- 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 29a2ae44c..000000000 --- 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 c85f4dfca..000000000 --- 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 f58a1a54e..000000000 --- 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 4fc024e4c..000000000 --- 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 45accf650..000000000 --- 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 6fdb86ab4..000000000 --- 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 10c231f98..000000000 --- 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 232df001f..000000000 --- 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 ac48dfb0f..087d300c1 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -762,10 +762,6 @@ class RustdeskImpl { throw UnimplementedError("mainGetError"); } - bool mainShowOption({required String key, dynamic hint}) { - throw UnimplementedError("mainShowOption"); - } - Future mainSetOption( {required String key, required String value, dynamic hint}) { js.context.callMethod('setByName', [ @@ -1377,6 +1373,10 @@ class RustdeskImpl { throw UnimplementedError("cmLoginRes"); } + Future cmCloseConnectionWindow({required int connId, dynamic hint}) { + throw UnimplementedError("cmCloseConnectionWindow"); + } + Future cmCloseConnection({required int connId, dynamic hint}) { throw UnimplementedError("cmCloseConnection"); } @@ -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/common.dart b/flutter/lib/web/common.dart index 4d539d5d4..a552752a8 100644 --- a/flutter/lib/web/common.dart +++ b/flutter/lib/web/common.dart @@ -1,5 +1,4 @@ import 'dart:js' as js; -import 'dart:html' as html; // cycle imports, maybe we can improve this import 'package:flutter_hbb/consts.dart'; @@ -13,8 +12,6 @@ final isWebDesktop_ = !js.context.callMethod('isMobile'); final isDesktop_ = false; -String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']); - final _localOs = js.context.callMethod('getByName', ['local_os', '']); final isWebOnWindows_ = _localOs == kPeerPlatformWindows; final isWebOnLinux_ = _localOs == kPeerPlatformLinux; diff --git a/flutter/lib/web/dummy.dart b/flutter/lib/web/dummy.dart index b9e3b80b6..0063c38bd 100644 --- a/flutter/lib/web/dummy.dart +++ b/flutter/lib/web/dummy.dart @@ -12,3 +12,5 @@ Future webSendLocalFiles( required bool isRemote}) { throw UnimplementedError("webSendLocalFiles"); } + +Future loadLocalTerminalFontIfNeeded() async {} diff --git a/flutter/lib/web/plugin/handlers.dart b/flutter/lib/web/plugin/handlers.dart deleted file mode 100644 index f159ce9dd..000000000 --- a/flutter/lib/web/plugin/handlers.dart +++ /dev/null @@ -1,14 +0,0 @@ -abstract class NativeHandler { - bool onEvent(Map evt); -} - -class NativeUiHandler extends NativeHandler { - NativeUiHandler._(); - - static NativeUiHandler instance = NativeUiHandler._(); - - @override - bool onEvent(Map evt) { - throw UnimplementedError(); - } -} diff --git a/flutter/lib/web/terminal_font.dart b/flutter/lib/web/terminal_font.dart new file mode 100644 index 000000000..964924e4c --- /dev/null +++ b/flutter/lib/web/terminal_font.dart @@ -0,0 +1,33 @@ +import 'dart:html' as html; +import 'dart:js' as js; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +bool _loadRequested = false; + +/// When Google CDNs are unreachable, `index.html` sets +/// `window.rustdeskLocalFonts` and `GoogleFonts.robotoMono()` cannot download +/// the terminal font. Load the copy bundled with the web app instead, +/// registered under the family name google_fonts gives the terminal's +/// TextStyle ('RobotoMono_regular'). +Future loadLocalTerminalFontIfNeeded() async { + if (_loadRequested || js.context['rustdeskLocalFonts'] != true) { + return; + } + _loadRequested = true; + try { + final req = await html.HttpRequest.request( + 'fonts/RobotoMono-Regular.ttf', + responseType: 'arraybuffer', + ); + final data = ByteData.view(req.response as ByteBuffer); + final loader = FontLoader('RobotoMono_regular') + ..addFont(Future.value(data)); + await loader.load(); + } catch (e) { + _loadRequested = false; + debugPrint('Failed to load bundled Roboto Mono: $e'); + } +} diff --git a/flutter/macos/Runner/MainFlutterWindow.swift b/flutter/macos/Runner/MainFlutterWindow.swift index 1cc72419b..336d94f1d 100644 --- a/flutter/macos/Runner/MainFlutterWindow.swift +++ b/flutter/macos/Runner/MainFlutterWindow.swift @@ -36,8 +36,28 @@ class RelativeMouseState { } class MainFlutterWindow: NSWindow { + private static let fullscreenWorkAreaSizes = NSMapTable( + keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: .strongMemory + ) + private static let fullscreenObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willEnterFullScreenNotification, + object: nil, + queue: .main + ) { notification in + guard let window = notification.object as? NSWindow, + let screen = window.screen else { + return + } + fullscreenWorkAreaSizes.setObject( + NSValue(size: screen.visibleFrame.size), + forKey: window + ) + } + override func awakeFromNib() { rustdesk_core_main(); + _ = MainFlutterWindow.fullscreenObserver let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController @@ -278,6 +298,16 @@ class MainFlutterWindow: NSWindow { self.disableNativeRelativeMouseMode() result(true) + case "getMacOSWorkAreaSize": + guard Thread.isMainThread, + let window = registrar.view?.window, + let size = MainFlutterWindow.fullscreenWorkAreaSizes + .object(forKey: window)?.sizeValue else { + result(nil) + break + } + result([Double(size.width), Double(size.height)]) + default: result(FlutterMethodNotImplemented) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index cba9ba5ea..84dd9cb0e 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -340,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba + resolved-ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3 url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.0" @@ -409,14 +409,6 @@ packages: url: "https://pub.dev" source: hosted version: "12.0.1" - external_path: - dependency: "direct main" - description: - name: external_path - sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4" - url: "https://pub.dev" - source: hosted - version: "1.0.3" ffi: dependency: "direct main" description: @@ -529,11 +521,12 @@ packages: flutter_custom_cursor: dependency: "direct main" description: - name: flutter_custom_cursor - sha256: "3850a32ac6de351ccc5e4286b6d94ff70c10abecd44479ea6c5aaea17264285d" - url: "https://pub.dev" - source: hosted - version: "0.0.4" + path: "." + ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e + resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e + url: "https://github.com/rustdesk-org/flutter_custom_cursor" + source: git + version: "0.0.3" flutter_gpu_texture_renderer: dependency: "direct main" description: @@ -1597,9 +1590,9 @@ packages: dependency: "direct main" description: path: "plugins/window_size" - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - url: "https://github.com/google/flutter-desktop-embedding.git" + ref: "51e67ce047c72b26810b99e8473ddb44612fe356" + resolved-ref: "51e67ce047c72b26810b99e8473ddb44612fe356" + url: "https://github.com/21pages/flutter-desktop-embedding.git" source: git version: "0.1.0" xdg_directories: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b9f8e1ccb..1868a426e 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.9+67 +version: 1.5.0+68 environment: sdk: '^3.1.0' @@ -29,7 +29,6 @@ dependencies: ffi: ^2.1.0 path_provider: ^2.1.1 - external_path: ^1.0.3 provider: ^6.0.5 tuple: ^2.0.0 wakelock_plus: ^1.1.3 @@ -58,12 +57,15 @@ dependencies: git: url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window freezed_annotation: ^2.0.3 - flutter_custom_cursor: ^0.0.4 + flutter_custom_cursor: + git: + url: https://github.com/rustdesk-org/flutter_custom_cursor + ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e window_size: git: - url: https://github.com/google/flutter-desktop-embedding.git + url: https://github.com/21pages/flutter-desktop-embedding.git path: plugins/window_size - ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + ref: 51e67ce047c72b26810b99e8473ddb44612fe356 get: ^4.6.5 visibility_detector: ^0.4.0+2 contextmenu: ^3.0.0 diff --git a/flutter/test/file_model_test.dart b/flutter/test/file_model_test.dart new file mode 100644 index 000000000..9455f2cab --- /dev/null +++ b/flutter/test/file_model_test.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_hbb/models/file_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:uuid/uuid.dart'; + +final _sessionId = UuidValue('00000000-0000-0000-0000-000000000000'); + +class _FakeFFI implements FFI { + @override + String id = 'test-peer'; + @override + UuidValue get sessionId => _sessionId; + @override + late final FfiModel ffiModel = FfiModel(WeakReference(this)); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +FileController _createController(FileFetcher fileFetcher) { + final ffi = _FakeFFI(); + return FileController( + isLocal: false, + getSessionID: () => _sessionId, + rootState: WeakReference(ffi), + jobController: JobController(() => _sessionId, () => null), + fileFetcher: fileFetcher, + getOtherSideDirectoryData: () => + DirectoryData(FileDirectory(), DirectoryOptions()), + ); +} + +FileDirectory _directory(String path) => FileDirectory()..path = path; + +String _directoryJson(String path) => jsonEncode({ + 'id': 0, + 'path': path, + 'entries': [], + }); + +class _SentRead { + final String path; + final bool includeHidden; + + const _SentRead(this.path, this.includeHidden); +} + +void main() { + test('a fast remote response is matched after registration', () async { + late final FileFetcher fileFetcher; + fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, __) { + fileFetcher.tryCompleteTask(_directoryJson(path), 'false'); + return Future.value(); + }, + ); + final directory = await fileFetcher.fetchDirectory('/fast', false, false); + expect(directory.path, '/fast'); + }); + + test('a send failure fails and removes its registered task', () async { + final failure = StateError('send failed'); + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) => Future.error(failure), + ); + await expectLater( + fileFetcher.fetchDirectory('/failed', false, false), + throwsA(same(failure)), + ); + expect(fileFetcher.hasPendingRemoteRead('/failed'), isFalse); + }); + + test('a resolved Home path completes the sole empty-path request', () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final home = controller.openDirectory(''); + await Future.delayed(Duration.zero); + final response = _directoryJson('/home/user'); + controller.initDirAndHome({'value': response}); + expect(controller.homePath, '/home/user'); + expect(controller.directory.value.path, '/initial'); + fileFetcher.tryCompleteTask(response, 'false'); + expect(await home, isTrue); + expect(controller.directory.value.path, '/home/user'); + expect(sent.single.path, isEmpty); + }); + + test('an automatic response initializes Home without a pending request', () { + final controller = _createController(FileFetcher(() => _sessionId)); + + controller.initDirAndHome({'value': _directoryJson('/home/user')}); + + expect(controller.homePath, '/home/user'); + expect(controller.directory.value.path, '/home/user'); + }); + + test('an exact path response is not taken by a pending Home request', + () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final home = fileFetcher.fetchDirectory('', false, false); + final regular = fileFetcher.fetchDirectory('/regular', false, false); + await Future.delayed(Duration.zero); + var homeCompleted = false; + home.then((_) => homeCompleted = true); + + fileFetcher.tryCompleteTask(_directoryJson('/unmatched'), 'false'); + await Future.delayed(Duration.zero); + expect(homeCompleted, isFalse); + + fileFetcher.tryCompleteTask(_directoryJson('/regular'), 'false'); + expect((await regular).path, '/regular'); + await Future.delayed(Duration.zero); + expect(homeCompleted, isFalse); + + fileFetcher.tryCompleteTask(_directoryJson('/home/user'), 'false'); + expect((await home).path, '/home/user'); + expect(sent.map((request) => request.path), ['', '/regular']); + }); + + test('a read error completes the sole pending request', () async { + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) async {}, + ); + final request = fileFetcher.fetchDirectory('/denied', false, false); + await Future.delayed(Duration.zero); + final expectation = expectLater(request, throwsA('permission denied')); + + fileFetcher.tryCompleteRemoteTaskWithError('permission denied'); + + await expectation; + expect(fileFetcher.hasPendingRemoteRead('/denied'), isFalse); + }); + + test('same-path requests share the pending read', () async { + final sent = <_SentRead>[]; + late final FileFetcher fileFetcher; + fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final first = controller.openDirectory('/same'); + final waiting = controller.openDirectory('/same'); + + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.path), ['/same']); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect(await first, isTrue); + await Future.delayed(Duration.zero); + + expect(sent.map((request) => request.path), ['/same']); + expect(await waiting, isTrue); + expect(controller.directory.value.path, '/same'); + }); + + test('same-path requests with different hidden options are serialized', + () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + + final first = fileFetcher.fetchDirectory('/same', false, false); + final second = fileFetcher.fetchDirectory('/same', false, true); + + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false]); + + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await first).path, '/same'); + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false, true]); + + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await second).path, '/same'); + }); + + test('session invalidation cancels active and waiting reads', () async { + final sent = <_SentRead>[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, path, includeHidden) async { + sent.add(_SentRead(path, includeHidden)); + }, + ); + final first = fileFetcher.fetchDirectory('/same', false, false); + final waiting = fileFetcher.fetchDirectory('/same', false, true); + await Future.delayed(Duration.zero); + final firstError = expectLater(first, throwsA(isA())); + final waitingError = expectLater(waiting, throwsA(isA())); + + fileFetcher.beginRemoteSession(); + + await firstError; + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false]); + await waitingError; + expect(fileFetcher.hasPendingRemoteRead('/same'), isFalse); + final replacement = fileFetcher.fetchDirectory('/same', false, true); + await Future.delayed(Duration.zero); + expect(sent.map((request) => request.includeHidden), [false, true]); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await replacement).path, '/same'); + }); + + test('a late dispatch failure cannot remove a replacement task', () async { + final dispatches = >[]; + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) { + final dispatch = Completer(); + dispatches.add(dispatch); + return dispatch.future; + }, + ); + final first = fileFetcher.fetchDirectory('/same', false, false); + await Future.delayed(Duration.zero); + final firstError = expectLater(first, throwsA(isA())); + fileFetcher.beginRemoteSession(); + await firstError; + + final replacement = fileFetcher.fetchDirectory('/same', false, false); + await Future.delayed(Duration.zero); + expect(dispatches, hasLength(2)); + dispatches.first.completeError(StateError('late dispatch failure')); + await Future.delayed(Duration.zero); + + expect(fileFetcher.hasPendingRemoteRead('/same'), isTrue); + fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false'); + expect((await replacement).path, '/same'); + dispatches.last.complete(); + await Future.delayed(Duration.zero); + }); + + test('navigation ignores stale directory responses', () async { + final fileFetcher = FileFetcher( + () => _sessionId, + readRemoteDirectory: (_, __, ___) async {}, + ); + final controller = _createController(fileFetcher); + controller.directory.value = _directory('/initial'); + + final stale = controller.openDirectory('/stale'); + final latest = controller.openDirectory('/latest'); + await Future.delayed(Duration.zero); + + fileFetcher.tryCompleteTask(_directoryJson('/latest'), 'false'); + expect(await latest, isTrue); + fileFetcher.tryCompleteTask(_directoryJson('/stale'), 'false'); + expect(await stale, isTrue); + + expect(controller.directory.value.path, '/latest'); + }); +} diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 5a1a76a77..bebf8d8af 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -342,11 +342,43 @@ void main() { }); group('shouldHandleTerminalPasteShortcut', () { + test('handles only Ctrl+Shift+V on Linux with a virtual lock', () { + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: true, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.linux, + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + test( 'keeps default xterm paste behavior when virtual modifiers are inactive', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -364,6 +396,7 @@ void main() { () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -377,6 +410,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.macOS, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -393,6 +427,7 @@ void main() { test('handles paste shortcut repeats while a virtual lock is active', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: true, @@ -409,6 +444,7 @@ void main() { test('ignores key-up and unmodified V events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: false, isKeyRepeat: false, @@ -422,6 +458,7 @@ void main() { ); expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -444,6 +481,7 @@ void main() { ]) { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyV, isKeyDown: true, isKeyRepeat: false, @@ -461,6 +499,7 @@ void main() { test('ignores non-V key events', () { expect( shouldHandleTerminalPasteShortcut( + platform: TargetPlatform.windows, logicalKey: LogicalKeyboardKey.keyC, isKeyDown: true, isKeyRepeat: false, diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart index d00646b2b..5581886b7 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 000000000..04af45250 --- /dev/null +++ b/flutter/test/terminal_mouse_handler_test.dart @@ -0,0 +1,299 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_hbb/models/terminal_mouse_handler.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +const _terminalSize = Size(400, 120); + +Widget _terminalHarness( + Terminal terminal, + TerminalController controller, +) => + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: _terminalSize.width, + height: _terminalSize.height, + child: TerminalMouseInteraction( + terminal, + controller: controller, + ), + ), + ), + ); + +void _writeLines(Terminal terminal, int count) => terminal.write( + List.generate(count, (index) => 'line $index\r\n').join(), + ); + +void main() { + late Terminal terminal; + late List output; + + setUp(() { + output = []; + terminal = Terminal(mouseHandler: const WheelButtonFixMouseHandler()) + ..onOutput = output.add; + }); + + testWidgets('Linux Ctrl+V is not paste', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (_) async => {'text': 'clipboard'}, + ); + final controller = TerminalController(); + addTearDown(controller.dispose); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + await tester.tap(find.byType(TerminalView)); + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + final controlVOutput = List.of(output); + output.clear(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(controlVOutput, ['\x16']); + expect(output, ['clipboard']); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + 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, + ); + }); + + testWidgets('dragging below scrolls and extends selection', (tester) async { + final controller = TerminalController(); + _writeLines(terminal, 80); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final terminalView = + tester.state(find.byType(TerminalView)); + final scrollController = terminalView.widget.scrollController!; + scrollController.jumpTo(0); + await tester.pump(); + final renderTerminal = terminalView.renderTerminal; + const localStart = Offset(20, 20); + final startCell = renderTerminal.getCellOffset(localStart); + final mouse = TestPointer(1, PointerDeviceKind.mouse); + final outside = Offset(20, renderTerminal.size.height); + await tester.handlePointerEventRecord([ + PointerEventRecord(Duration.zero, [ + mouse.down(renderTerminal.localToGlobal(localStart)), + mouse.move(renderTerminal.localToGlobal(outside)), + ]), + PointerEventRecord(const Duration(milliseconds: 150), [ + mouse.move( + renderTerminal.localToGlobal(outside + const Offset(1, 1)), + ), + mouse.up(), + ]), + ]); + + expect(scrollController.offset, greaterThan(0)); + expect(controller.selection!.begin, startCell); + expect(controller.selection!.end.y, greaterThan(startCell.y)); + final releasedOffset = scrollController.offset; + await tester.pump(const Duration(milliseconds: 100)); + expect(scrollController.offset, releasedOffset); + }); + + testWidgets('tmux mouse input is reported without local selection', + (tester) async { + final controller = TerminalController(); + terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword'); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final renderTerminal = tester + .state(find.byType(TerminalView)) + .renderTerminal; + const wheel = Offset(120, 40); + await tester.sendEventToBinding( + PointerScrollEvent( + position: renderTerminal.localToGlobal(wheel), + scrollDelta: const Offset(0, 40), + ), + ); + await tester.pump(); + final wheelCell = renderTerminal.getCellOffset(wheel); + expect(output.first, '\x1b[<65;${wheelCell.x + 1};${wheelCell.y + 1}M'); + output.clear(); + final clickPosition = + renderTerminal.getOffset(const CellOffset(0, 0)) + const Offset(1, 1); + final clickCell = renderTerminal.getCellOffset(clickPosition); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.down(renderTerminal.localToGlobal(clickPosition)); + await mouse.up(); + await tester.pump(); + await mouse.down(renderTerminal.localToGlobal(clickPosition)); + await mouse.up(); + await tester.pump(kDoubleTapTimeout); + expect(output, [ + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M', + '\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m', + ]); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isFalse); + output.clear(); + const start = Offset(40, 40); + const end = Offset(240, 80); + final startCell = renderTerminal.getCellOffset(start); + final endCell = renderTerminal.getCellOffset(end); + await mouse.down(renderTerminal.localToGlobal(start)); + await mouse.moveTo(renderTerminal.localToGlobal(end)); + await tester.pump(); + + expect(output, [ + '\x1b[<0;${startCell.x + 1};${startCell.y + 1}M', + '\x1b[<32;${endCell.x + 1};${endCell.y + 1}M', + ]); + expect(controller.selection, isNull); + await mouse.up(); + expect(output.last, '\x1b[<0;${endCell.x + 1};${endCell.y + 1}m'); + expect(controller.suspendedPointerInputs, isFalse); + }); + + testWidgets('tmux drag stays suppressed after mouse mode is disabled', + (tester) async { + final controller = TerminalController(); + terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword'); + await tester.pumpWidget(_terminalHarness(terminal, controller)); + final renderTerminal = tester + .state(find.byType(TerminalView)) + .renderTerminal; + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + final start = renderTerminal.localToGlobal(const Offset(40, 40)); + final end = renderTerminal.localToGlobal(const Offset(240, 80)); + await mouse.down(start); + output.clear(); + terminal.write('\x1b[?1002l'); + await mouse.moveTo(end); + + expect(output, isEmpty); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isTrue); + terminal.write('\x1b[?1002h'); + await mouse.moveTo(start); + expect(output, isEmpty); + expect(controller.selection, isNull); + await mouse.up(); + expect(output, isEmpty); + expect(controller.suspendedPointerInputs, isFalse); + + await mouse.down(start); + output.clear(); + terminal.write('\x1b[?1002l'); + await mouse.up(); + await tester.pump(kDoubleTapTimeout); + + expect(output, isEmpty); + expect(controller.selection, isNull); + expect(controller.suspendedPointerInputs, isFalse); + }); +} diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index afe2f2f31..7e15791e9 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true} percent-encoding = {version ="2.3", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true} -fuser = {version = "0.15", default-features = false, optional = true} +fuser = {git="https://github.com/rustdesk-org/fuser", branch = "refact/tag-0.16.0-cargo-1.75.0", default-features = false, optional = true} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index d918ee1db..c32a20259 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); + result = CliprdrEnumFORMATETC_New(instance->m_nNumFormats, instance->m_pFormatEtc, + &clone); + if (FAILED(result)) + { + *ppEnum = NULL; + return result; + } - if (!*ppEnum) - return E_OUTOFMEMORY; - - ((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) + if (!context || !capabilities || + capabilities->cCapabilitiesSets > 1 || + (capabilities->cCapabilitiesSets == 1 && !capabilities->capabilitySets)) + return ERROR_INTERNAL_ERROR; + + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) return ERROR_INTERNAL_ERROR; for (index = 0; index < capabilities->cCapabilitiesSets; index++) @@ -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) + /* 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) { - /* dropFiles contains file names */ - for (wFileName = (WCHAR *)((char *)dropFiles + dropFiles->pFiles); - (len = wcslen(wFileName)) > 0; wFileName += len + 1) + remaining = dropFilesSize - dropFiles->pFiles; + if (dropFiles->fWide && (dropFiles->pFiles % sizeof(WCHAR)) == 0) { - wf_cliprdr_process_filename(clipboard, wFileName, wcslen(wFileName)); - } - } - else - { - char *p; - for (p = (char *)((char *)dropFiles + dropFiles->pFiles); (len = strlen(p)) > 0; - p += len + 1, clipboard->nFiles++) - { - int cchWideChar; - cchWideChar = MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, NULL, 0); - wFileName = (LPWSTR)calloc(cchWideChar, sizeof(WCHAR)); - if (wFileName) + wFileName = (WCHAR *)((BYTE *)dropFiles + dropFiles->pFiles); + while (remaining >= sizeof(WCHAR)) { - MultiByteToWideChar(CP_ACP, MB_COMPOSITE, p, len, wFileName, cchWideChar); - wf_cliprdr_process_filename(clipboard, wFileName, cchWideChar); - free(wFileName); + 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 + } + else if (!dropFiles->fWide) + { + char *name = (char *)dropFiles + dropFiles->pFiles; + while (remaining > 0) { - rc = ERROR_INTERNAL_ERROR; - GlobalUnlock(stg_medium.hGlobal); - ReleaseStgMedium(&stg_medium); - goto exit; + 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); + 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 @@ exit: 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; + return wf_do_empty_cliprdr(clipboard, connID); +} + +BOOL wf_do_empty_cliprdr(wfClipboard *clipboard, UINT32 connID) +{ + if (!clipboard || !clipboard->hwnd) + return FALSE; + + /* 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; +} + +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) + { + clipboard_ctx->data_obj = NULL; + wf_destroy_file_obj(expected); + } + return ReleaseMutex(clipboard_ctx->data_obj_mutex); +} + +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 (instance) { - if (instance->m_connID != connID) + current = OleIsCurrentClipboard(instance); + if (current != S_OK) { - return TRUE; - } - } - - return wf_do_empty_cliprdr(clipboard); -} - -BOOL wf_do_empty_cliprdr(wfClipboard *clipboard) -{ - BOOL rc = FALSE; - if (!clipboard) - { - return FALSE; - } - - clipboard->copied = FALSE; - - if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) - { - return FALSE; - } - - do - { - if (clipboard->data_obj != NULL) - { - wf_destroy_file_obj(clipboard->data_obj); - clipboard->data_obj = NULL; - } - - /* discard all contexts in clipboard */ - if (!try_open_clipboard(clipboard->hwnd)) - { - DEBUG_CLIPRDR("OpenClipboard failed with 0x%x", GetLastError()); - rc = FALSE; - break; - } - - if (is_file_descriptor_from_remote()) - { - 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); - - if (!ReleaseMutex(clipboard->data_obj_mutex)) - { - // critical error!!! } - return rc; + + /* 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; + } + + /* OpenClipboard stabilizes the contents; do not clear if they changed while opening. */ + if (clipboard_sequence == GetClipboardSequenceNumber() && + (instance || is_file_descriptor_from_remote()) && !EmptyClipboard()) + { + DEBUG_CLIPRDR("EmptyClipboard failed with 0x%x", GetLastError()); + result = FALSE; + } + + close_succeeded = CloseClipboard(); + if (!close_succeeded) + DEBUG_CLIPRDR("CloseClipboard failed with 0x%x", GetLastError()); + if (instance) + { + if (result && !wf_release_data_obj_if_same(clipboard_ctx, instance)) + result = FALSE; + IDataObject_Release(instance); + } + + return close_succeeded && result; +} + +static BOOL wf_empty_cliprdr_on_sta(wfClipboard *clipboard_ctx, UINT32 connID) +{ + CliprdrDataObject *instance; + + if (!clipboard_ctx) + return FALSE; + if (WaitForSingleObject(clipboard_ctx->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) + return FALSE; + + instance = (CliprdrDataObject *)clipboard_ctx->data_obj; + /* Without a tracked object, continue so stale remote file formats can still be cleared. */ + if (connID != 0 && instance && instance->m_connID != connID) + return ReleaseMutex(clipboard_ctx->data_obj_mutex); + + clipboard_ctx->copied = FALSE; + if (instance) + IDataObject_AddRef((IDataObject *)instance); + if (!ReleaseMutex(clipboard_ctx->data_obj_mutex)) + { + if (instance) + IDataObject_Release((IDataObject *)instance); + return FALSE; + } + return wf_empty_clipboard_on_sta(clipboard_ctx, (IDataObject *)instance); } diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index c16be3469..4e379407f 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 69cea8daf..b2b1ac453 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 69cea8dafee147848ae88702029f4bf7df7224c3 +Subproject commit b2b1ac453d1d694046f63be20d792d608dac1c93 diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 602781b67..165d98349 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.9" +version = "1.5.0" edition = "2021" description = "RustDesk Remote Desktop" diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 0af7dfe0f..bab2b4e9f 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -11,6 +11,16 @@ edition = "2018" [features] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] +# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) +# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is +# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.4). We deliberately do +# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree +# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. +# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of +# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always +# enable `scrap/wayland`, which is what hid this. +drm = ["wayland", "hbb_common/wayland_probe"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs new file mode 100644 index 000000000..3d19c6c41 --- /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 000000000..6df6ea61d --- /dev/null +++ b/libs/scrap/src/common/drm_render.rs @@ -0,0 +1,184 @@ +// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout +// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd +// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to +// its own CPU-mapped grab (`drmtap_grab_mapped`). + +use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; +use super::Pixfmt; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::RawFd; + +use super::drm_reader::{ + DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888, + MAX_DIM, MAX_FRAME_BYTES, +}; + +/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context +/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread. +pub struct RenderConverter { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, +} + +impl RenderConverter { + /// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection. + pub fn open_render(node: Option<&str>) -> Option { + let lib = drmtap_dl::get()?; + let open_render = lib.open_render; + let node_cstr = match node.filter(|n| !n.is_empty()) { + None => None, + // Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check. + Some(n) => match super::drm_reader::device_under_dev_dri(n) { + None => { + log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting"); + None + } + Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()), + }, + }; + // SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection. + let ctx = unsafe { + open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr())) + }; + if ctx.is_null() { + log::info!( + "drmtap_open_render({}) failed; no usable DRM render node", + node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}")) + ); + return None; + } + match node_cstr { + Some(c) => log::info!( + "drm: opened unprivileged convert context on the exporting GPU ({c:?})" + ), + None => log::info!( + "drm: opened unprivileged render-node convert context (auto-selected)" + ), + } + Some(RenderConverter { lib, ctx }) + } + + /// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`. + pub fn convert( + &mut self, + desc: &mut drmtap_dmabuf_desc, + received_fd: RawFd, + ) -> io::Result<(&[u8], u32, u32, Pixfmt)> { + { + let (w, h) = (desc.width, desc.height); + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"), + )); + } + // Reject, do not clamp, and write the normalized count back so the C reads the count bounded here. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing a dma-buf descriptor with num_planes {} (1..=4)", + desc.num_planes + ), + )); + } + desc.num_planes = planes; + let planes = planes as usize; + for p in 0..planes { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + } + let convert_dmabuf = self.lib.convert_dmabuf; + // LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`. + desc.dma_buf_fd = received_fd; + // SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed + // before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h). + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + "drmtap_convert_dmabuf produced an empty frame", + )); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}", + frame.format + ), + )); + } + let len = match stride.checked_mul(h as usize) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"), + )); + } + }; + let pixfmt = match frame.format { + DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA, + DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA, + // Unset by an older convert -> libdrmtap's normalized BGRA. + 0 => Pixfmt::BGRA, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"), + )); + } + }; + let data = std::slice::from_raw_parts(frame.data as *const u8, len); + Ok((data, w, h, pixfmt)) + } + } +} + +impl Drop for RenderConverter { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps + // this drop on the thread that created and used it (thread-local EGL + cached imports). + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs new file mode 100644 index 000000000..0312c75bb --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,421 @@ +// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency. + +use hbb_common::{libloading::Library, log}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::OnceLock; + +// C ABI structs: must match libdrmtap include/drmtap.h. + +#[repr(C)] +pub struct drmtap_ctx { + _private: [u8; 0], +} + +#[repr(C)] +pub struct drmtap_config { + pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card* + pub crtc_id: u32, // 0 = auto-select first active CRTC + pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN) + pub debug: c_int, +} + +impl Default for drmtap_config { + fn default() -> Self { + Self { + device_path: std::ptr::null(), + crtc_id: 0, + helper_path: std::ptr::null(), + debug: 0, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_display { + pub crtc_id: u32, + pub connector_id: u32, + pub name: [c_char; 32], + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + pub active: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_device { + pub path: [c_char; 64], + pub render_node: [c_char; 64], + pub driver: [c_char; 32], + pub display_count: u32, +} + +#[repr(C)] +pub struct drmtap_frame_info { + pub data: *mut c_void, + pub dma_buf_fd: c_int, + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: u32, + pub modifier: u64, + pub fb_id: u32, + pub _priv: *mut c_void, +} + +// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via +// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS. +// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_dmabuf_desc { + pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc of the scanout + pub modifier: u64, // DRM format modifier (tiling/compression) + pub fb_id: u32, // import-once cache key; 0 disables caching + pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1 + pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color) + pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride + pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3) + pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown +} + +impl Default for drmtap_dmabuf_desc { + fn default() -> Self { + Self { + dma_buf_fd: -1, + width: 0, + height: 0, + format: 0, + modifier: 0, + fb_id: 0, + num_planes: 0, + offsets: [0; 4], + pitches: [0; 4], + hdr_eotf: 0, + hdr_max_nits: 0, + } + } +} + +#[repr(C)] +pub struct drmtap_cursor_info { + pub x: i32, + pub y: i32, + pub hot_x: i32, + pub hot_y: i32, + pub width: u32, + pub height: u32, + pub pixels: *mut u32, + pub visible: c_int, + pub _priv: *mut c_void, +} + +// Resolved symbol typedefs. + +type FnVersion = unsafe extern "C" fn() -> c_int; +type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx; +type FnClose = unsafe extern "C" fn(*mut drmtap_ctx); +type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int; +type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int; +type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int; +type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info); +type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int; +type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info); +// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged +// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side. +type FnGrabDesc = + unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; +type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx; +// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none. +type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char; +type FnConvertDmabuf = + unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; + +/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid. +pub struct DrmtapLib { + _lib: Library, + pub open: FnOpen, + pub close: FnClose, + pub list_displays: FnListDisplays, + pub list_devices: Option, + pub grab_mapped: FnGrabMapped, + pub frame_release: FnFrameRelease, + pub get_cursor: FnGetCursor, + pub cursor_release: FnCursorRelease, + pub grab_desc: FnGrabDesc, + pub open_render: FnOpenRender, + pub convert_dmabuf: FnConvertDmabuf, + pub render_node: Option, + pub version: (c_int, c_int, c_int), +} + +// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability; +// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out. +unsafe impl Send for DrmtapLib {} +unsafe impl Sync for DrmtapLib {} + +const DRMTAP_ABI_MAJOR: c_int = 0; + +// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read +// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API +// has been present since 0.4.10. +const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0); + +// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes +// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets. +const DRMTAP_ABI_MINOR: c_int = 5; + +/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor). +fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool { + major == DRMTAP_ABI_MAJOR + && minor == DRMTAP_ABI_MINOR + && (minor, patch) >= DRMTAP_MIN_MINOR_PATCH +} + +impl DrmtapLib { + fn load() -> Option { + // Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so. + const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0"; + // Bare sonames exist so an unpackaged development build can load a locally built .so from + // the normal ld.so search path. They are NOT offered when running as root: this is the one + // place where which file happens to be on the load path decides what gets mapped into the + // CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first + // anyway. A root process that reaches the fallback has no bundled library, which is the + // PipeWire-fallback case, not a reason to search. + const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"]; + let is_root = unsafe { hbb_common::libc::geteuid() } == 0; + let candidates: Vec<&str> = if is_root { + vec![INSTALLED] + } else { + std::iter::once(INSTALLED).chain(DEV_ONLY).collect() + }; + unsafe { + let mut errs = Vec::new(); + let found = candidates.iter().find_map(|n| match Library::new(*n) { + Ok(l) => Some((l, *n)), + Err(e) => { + errs.push(format!("{n}: {e}")); + None + } + }); + let Some((lib, name)) = found else { + // The dlerror names the real cause (a missing soname, a glibc too old for the + // bundled build); the caller only reports that DRM capture is off. + log::warn!("libdrmtap dlopen failed: {}", errs.join("; ")); + return None; + }; + // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare + // soname, while `canonicalize` resolves a relative name against it. + let real = std::path::Path::new(name) + .is_absolute() + .then(|| std::fs::canonicalize(name).ok()) + .flatten(); + let version: FnVersion = *lib.get(b"drmtap_version").ok()?; + let v = version(); + let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + if !abi_accepted(major, minor, patch) { + let why = if major != DRMTAP_ABI_MAJOR { + "the struct layouts this build mirrors track the ABI major, so reading a \ + frame descriptor through a mismatched one would mis-decode it" + } else if minor != DRMTAP_ABI_MINOR { + "this build mirrors the struct layouts of one minor and only that one; \ + under 0.x semver the minor is the breaking axis, so an unverified minor \ + could be read at the wrong offsets. Widening it is a deliberate act, done \ + with the layouts re-checked field by field" + } else { + "it predates the split-capture API, so its only capture path converts \ + in-process, which in the root service means loading the GL stack there" + }; + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \ + use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \ + v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \ + falling back to PipeWire/portal." + ); + return None; + } + let open: FnOpen = *lib.get(b"drmtap_open").ok()?; + let close: FnClose = *lib.get(b"drmtap_close").ok()?; + let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?; + let list_devices: Option = + lib.get(b"drmtap_list_devices").ok().map(|s| *s); + let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?; + let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?; + let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?; + let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?; + let grab: Option = lib.get(b"drmtap_grab_desc").ok().map(|s| *s); + let open_r: Option = lib.get(b"drmtap_open_render").ok().map(|s| *s); + let conv: Option = + lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s); + let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) { + (Some(g), Some(o), Some(c)) => (g, o, c), + (grab, open_r, conv) => { + let mut missing = Vec::new(); + if grab.is_none() { + missing.push("drmtap_grab_desc"); + } + if open_r.is_none() { + missing.push("drmtap_open_render"); + } + if conv.is_none() { + missing.push("drmtap_convert_dmabuf"); + } + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \ + {}: it is a stale or pre-release build, not the version it claims. \ + Refusing to load; falling back to PipeWire/portal.", + missing.join(", ") + ); + return None; + } + }; + let render_node: Option = + lib.get(b"drmtap_render_node").ok().map(|s| *s); + // Log the load only now that every required symbol resolved: this fn still returns None on a missing one. + let loaded_from = real + .as_ref() + .map_or_else(|| name.to_owned(), |p| p.display().to_string()); + if loaded_from == name { + log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})"); + } else { + log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})"); + } + let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none()); + if (minor, patch) >= (4, 15) && (no_node || no_devices) { + let missing = if no_node && no_devices { + "drmtap_render_node and drmtap_list_devices" + } else if no_node { + "drmtap_render_node" + } else { + "drmtap_list_devices" + }; + let effect = if no_node && no_devices { + "Multi-GPU display enumeration and exporting-GPU selection stay disabled." + } else if no_node { + "Exporting-GPU selection stays disabled." + } else { + "Multi-GPU display enumeration stays disabled." + }; + log::warn!( + "libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \ + {missing}: it is a stale or pre-release build. Check what the soname symlink \ + points at and remove any leftover libdrmtap.so.0* beside it. {effect}" + ); + } + Some(DrmtapLib { + _lib: lib, + open, + close, + list_displays, + list_devices, + grab_mapped, + frame_release, + get_cursor, + cursor_release, + grab_desc, + open_render, + convert_dmabuf, + render_node, + version: (major, minor, patch), + }) + } + } +} + +static DRMTAP_LIB: OnceLock> = OnceLock::new(); + +/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered. +pub fn get() -> Option<&'static DrmtapLib> { + DRMTAP_LIB + .get_or_init(|| { + let lib = DrmtapLib::load(); + if lib.is_none() { + log::info!("libdrmtap not available or not usable; DRM capture disabled"); + } + lib + }) + .as_ref() +} + +#[cfg(test)] +mod tests { + use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH}; + + #[test] + fn abi_gate_rejects_a_library_from_before_the_split() { + // These are refused because their MINOR differs from the verified one, which is the only + // reason the gate needs. Naming the pre-split releases keeps the intent readable, but do + // not read this as the floor doing the work: see the test below. + for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is not the verified minor and must be refused" + ); + } + } + + #[test] + fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() { + // With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the + // minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is + // the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10) + // did for the split API. This test exists so nobody reads the pre-split test above as + // evidence that the floor is live -- if that ever matters, this assert is the tripwire. + let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH; + assert_eq!( + floor_minor, DRMTAP_ABI_MINOR, + "the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable" + ); + if floor_patch == 0 { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0), + "patch 0 of the verified minor must be accepted while the floor is 0" + ); + } else { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1)); + } + } + + #[test] + fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() { + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch)); + for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is a patch of the verified minor and must be accepted" + ); + } + } + + #[test] + fn abi_gate_rejects_an_unknown_newer_minor() { + // Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the + // NEW verified minor must be refused -- which is what a hardcoded list did before. + let verified = DRMTAP_ABI_MINOR; + for (minor, patch) in [ + (verified - 1, 99), + (verified + 1, 0), + (verified + 1, 99), + (verified + 4, 9), + ] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is an unverified minor and must be refused" + ); + } + } + + #[test] + fn abi_gate_rejects_another_major_in_both_directions() { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0)); + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99)); + } +} diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0d..1efed1176 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,6 +16,12 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drmtap_dl; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_reader; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_render; pub use self::linux::*; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index bed90fd76..1a9f29f25 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -19,6 +19,18 @@ static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool = const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); +// drm builds only: an unnamed-endpoint failure there forks the probe child, and the pollers +// turn every few hundred milliseconds. Every other failure is one cheap in-process error. +#[cfg(any(test, feature = "drm"))] +const FAILED_LOOKUP_BACKOFF: Duration = Duration::from_secs(5); + +#[cfg(any(test, feature = "drm"))] +static LAST_FAILED_LOOKUP: Mutex> = Mutex::new(None); + +#[cfg(feature = "drm")] +static LOOKUP_FAILURE_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + pub struct Displays { pub primary: usize, pub displays: Vec, @@ -171,11 +183,76 @@ fn get_primary_monitor() -> Option { .or_else(try_gdbus_primary) } +// Pure, so the backoff policy is testable without a compositor. +#[cfg(any(test, feature = "drm"))] +fn lookup_allowed(failed_at: Option, now: Instant) -> bool { + failed_at.map_or(true, |at| { + now.saturating_duration_since(at) >= FAILED_LOOKUP_BACKOFF + }) +} + +#[cfg(feature = "drm")] +fn backed_off() -> bool { + let failed_at = *LAST_FAILED_LOOKUP.lock().unwrap(); + !lookup_allowed(failed_at, Instant::now()) +} + +// Mirrors the probe module's gate, latch included: connecting consumes WAYLAND_SOCKET, so a +// once-named endpoint must stay named for the life of the process. +#[cfg(feature = "drm")] +fn endpoint_named() -> bool { + use std::sync::atomic::{AtomicBool, Ordering}; + static WAS_NAMED: AtomicBool = AtomicBool::new(false); + let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())); + if named { + WAS_NAMED.store(true, Ordering::Release); + } + WAS_NAMED.load(Ordering::Acquire) +} + +// Enumerates and keeps the failure stamp current. Suppresses nothing itself: one-shot callers +// (session init, pipewire) must always get a fresh read, or a transient failure latches. +fn enumerate_displays() -> hbb_common::ResultType> { + // Read before connecting, which consumes WAYLAND_SOCKET. + #[cfg(feature = "drm")] + let named = endpoint_named(); + let probed = get_wayland_displays(); + // Only the failure that would fork stamps; a named endpoint fails cheaply in-process. + #[cfg(feature = "drm")] + { + *LAST_FAILED_LOOKUP.lock().unwrap() = (probed.is_err() && !named).then(Instant::now); + if let Err(err) = &probed { + if !LOOKUP_FAILURE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + warn!("Failed to get wayland displays: {}", err); + } + } else { + LOOKUP_FAILURE_WARNED.store(false, std::sync::atomic::Ordering::Relaxed); + } + } + probed +} + +// True when a lookup now could neither hit the cache nor probe. Pollers skip their turn on +// it and keep their last published state; one-shot callers must not consult it. +#[cfg(feature = "drm")] +pub fn wayland_lookup_suppressed() -> bool { + DISPLAYS.lock().unwrap().is_none() && backed_off() +} + +// Whether any failure stamp exists, expired or not: pollers use it to tell a first failure +// from one that has already persisted across a backoff. +#[cfg(feature = "drm")] +pub fn wayland_failure_stamped() -> bool { + LAST_FAILED_LOOKUP.lock().unwrap().is_some() +} + pub fn get_displays() -> Arc { let mut lock = DISPLAYS.lock().unwrap(); match lock.as_ref() { Some(displays) => displays.clone(), - None => match get_wayland_displays() { + None => match enumerate_displays() { Ok(displays) => { let mut primary_index = None; if let Some(name) = get_primary_monitor() { @@ -201,8 +278,9 @@ pub fn get_displays() -> Arc { *lock = Some(displays.clone()); displays } - Err(err) => { - warn!("Failed to get wayland displays: {}", err); + Err(_err) => { + #[cfg(not(feature = "drm"))] + warn!("Failed to get wayland displays: {}", _err); Arc::new(Displays { primary: 0, displays: Vec::new(), @@ -215,6 +293,8 @@ pub fn get_displays() -> Arc { #[inline] pub fn clear_wayland_displays_cache() { let _ = DISPLAYS.lock().unwrap().take(); + // The failure stamp survives on purpose: it describes the seat, not the cache, and the + // capturer rebuild loop clears about once a second. } // Return (min_x, max_x, min_y, max_y) @@ -223,17 +303,21 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { 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 +// The desktop rect and per-display logical rects, read live from the compositor in a single +// roundtrip (drm builds may skip a turn during the failure backoff). Skips the displays cache +// and the primary-monitor detection, cheap enough to poll. rustdesk/rustdesk#15601 pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec)> { - match get_wayland_displays() { + #[cfg(feature = "drm")] + if backed_off() { + return None; + } + match enumerate_displays() { Ok(displays) => { desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays))) } - Err(err) => { - warn!("Failed to get wayland displays: {}", err); + Err(_err) => { + #[cfg(not(feature = "drm"))] + warn!("Failed to get wayland displays: {}", _err); None } } @@ -386,6 +470,40 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e mod tests { use super::*; + #[test] + fn test_lookup_backoff_boundaries() { + // Future `now`s sidestep Instant subtraction, which can panic near boot. + let failed_at = Instant::now(); + assert!(lookup_allowed(None, failed_at)); + assert!(!lookup_allowed( + Some(failed_at), + failed_at + FAILED_LOOKUP_BACKOFF / 2 + )); + assert!(lookup_allowed( + Some(failed_at), + failed_at + FAILED_LOOKUP_BACKOFF + )); + } + + #[test] + fn test_lookup_stamp_from_the_future_only_waits() { + // saturating_duration_since answers zero rather than underflowing. + let now = Instant::now(); + assert!(!lookup_allowed(Some(now + FAILED_LOOKUP_BACKOFF), now)); + } + + #[test] + fn test_clear_keeps_the_failure_stamp() { + // The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears, + // and dropping the stamp with it would defeat the backoff. Sole test touching these + // statics; serialize before adding another. + *LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now()); + clear_wayland_displays_cache(); + let stamp = *LAST_FAILED_LOOKUP.lock().unwrap(); + assert!(stamp.is_some()); + *LAST_FAILED_LOOKUP.lock().unwrap() = None; + } + fn display( x: i32, y: i32, diff --git a/res/PKGBUILD b/res/PKGBUILD index 8f3cc9c81..f66d62511 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.4.9 +pkgver=1.5.0 pkgrel=0 epoch= pkgdesc="" @@ -7,7 +7,7 @@ arch=('x86_64') url="" license=('AGPL-3.0') groups=() -depends=('gtk3' 'xdotool' 'libxcb' 'libxfixes' 'alsa-lib' 'libva' 'libappindicator-gtk3' 'pam' 'gst-plugins-base' 'gst-plugin-pipewire') +depends=('gtk3' 'xdotool' 'libxcb' 'libxfixes' 'alsa-lib' 'libva' 'libappindicator-gtk3' 'gst-plugins-base' 'gst-plugin-pipewire') makedepends=() checkdepends=() optdepends=() diff --git a/res/msi/Package/Components/Regs.wxs b/res/msi/Package/Components/Regs.wxs index 33d587b1e..25988f4a8 100644 --- a/res/msi/Package/Components/Regs.wxs +++ b/res/msi/Package/Components/Regs.wxs @@ -5,6 +5,23 @@ + + + + + + + + + + + + + + + + + @@ -40,17 +57,29 @@ - - - + + - - - - + + + + + + + + + + + + + + + + + diff --git a/res/msi/Package/Fragments/AddRemoveProperties.wxs b/res/msi/Package/Fragments/AddRemoveProperties.wxs index ac1d85a86..9f1460234 100644 --- a/res/msi/Package/Fragments/AddRemoveProperties.wxs +++ b/res/msi/Package/Fragments/AddRemoveProperties.wxs @@ -27,10 +27,12 @@ + + - + diff --git a/res/msi/Package/Language/Package.en-us.wxl b/res/msi/Package/Language/Package.en-us.wxl index c65a5126d..74919e04d 100644 --- a/res/msi/Package/Language/Package.en-us.wxl +++ b/res/msi/Package/Language/Package.en-us.wxl @@ -21,8 +21,6 @@ This file contains the declaration of all the localizable strings. - - @@ -35,8 +33,6 @@ This file contains the declaration of all the localizable strings. - - diff --git a/res/msi/Package/Package.wxs b/res/msi/Package/Package.wxs index 78cdf837b..fa1660abd 100644 --- a/res/msi/Package/Package.wxs +++ b/res/msi/Package/Package.wxs @@ -20,6 +20,7 @@ msi is built without template mode and keeps a single cabinet. --> + @@ -28,10 +29,11 @@ - + + @@ -51,7 +53,9 @@ - + + + diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index 90190d028..ffbd47880 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -11,6 +11,7 @@ import re import platform from pathlib import Path import shutil +from xml.sax.saxutils import quoteattr g_indent_unit = "\t" g_version = "" @@ -53,14 +54,14 @@ def make_parser(): parser.add_argument( "--arp", action="store_true", - help="Is ARPSYSTEMCOMPONENT", + help="Deprecated; native MSI ARP registration is always used.", default=False, ) parser.add_argument( "--custom-arp", type=str, default="{}", - help='Custom arp properties, e.g. \'["Comments": {"msi": "ARPCOMMENTS", "v": "Remote control application."}]\'', + help='Custom arp properties, e.g. \'{"Comments": {"msi": "ARPCOMMENTS", "v": "Remote control application."}}\'', ) parser.add_argument( "-c", "--custom", action="store_true", help="Is custom client", default=False @@ -336,25 +337,19 @@ def gen_custom_dialog_bitmaps(): ) -def gen_custom_ARPSYSTEMCOMPONENT_False(args): +def gen_native_arp_properties(): def func(lines, index_start): indent = g_indent_unit * 2 lines_new = [] - lines_new.append( - f"{indent}\n" - ) - lines_new.append( - f'{indent}\n\n' - ) - lines_new.append( f"{indent}\n" ) for _, v in g_arpsystemcomponent.items(): if "msi" in v and "v" in v: lines_new.append( - f'{indent}\n' + f'{indent}\n' ) for i, line in enumerate(lines_new): @@ -369,94 +364,16 @@ def gen_custom_ARPSYSTEMCOMPONENT_False(args): ) -def get_folder_size(folder_path): - total_size = 0 - - folder = Path(folder_path) - for file in folder.glob("**/*"): - if file.is_file(): - total_size += file.stat().st_size - - return total_size - - -def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir): +def gen_install_state_values(): def func(lines, index_start): indent = g_indent_unit * 5 - lines_new = [] - lines_new.append( - f"{indent}\n" - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - installDate = datetime.datetime.now().strftime("%Y%m%d") - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - # EstimatedSize in uninstall registry must be in KB. - estimated_size_bytes = get_folder_size(dist_dir) - estimated_size = max(1, (estimated_size_bytes + 1023) // 1024) - lines_new.append( - f'{indent}\n' - ) - - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - vs = g_version.split(".") - major, minor, build = vs[0], vs[1], vs[2] - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - lines_new.append( - f'{indent}\n' - ) - - lines_new.append( - f'{indent}\n' - ) - for k, v in g_arpsystemcomponent.items(): - if "v" in v: - t = v["t"] if "t" in v is None else "string" + for name, value in g_arpsystemcomponent.items(): + if "msi" not in value and "v" in value: + value_type = value.get("t", "string") lines_new.append( - f'{indent}\n' + f'{indent}\n' ) for i, line in enumerate(lines_new): @@ -465,24 +382,35 @@ def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir): return gen_content_between_tags( "Package/Components/Regs.wxs", - "", - "", + "", + "", func, ) -def gen_custom_ARPSYSTEMCOMPONENT(args, dist_dir): +def gen_custom_ARPSYSTEMCOMPONENT(args, _dist_dir): try: - custom_arp = json.loads(args.custom_arp) - g_arpsystemcomponent.update(custom_arp) - except json.JSONDecodeError as e: + custom_arp = dict(json.loads(args.custom_arp)) + except (json.JSONDecodeError, TypeError, ValueError) as e: print(f"Failed to decode custom arp: {e}") return False - if args.arp: - return gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir) - else: - return gen_custom_ARPSYSTEMCOMPONENT_False(args) + if any(not isinstance(value, dict) for value in custom_arp.values()): + print("Custom arp entries must be objects.") + return False + + if any( + isinstance(value, dict) and value.get("msi") == "ARPSYSTEMCOMPONENT" + for value in custom_arp.values() + ): + print("ARPSYSTEMCOMPONENT is not allowed; native MSI ARP registration must remain visible.") + return False + + g_arpsystemcomponent.update(custom_arp) + + if not gen_native_arp_properties(): + return False + return gen_install_state_values() def gen_conn_type(args): def func(lines, index_start): diff --git a/res/pam.d/rustdesk.debian b/res/pam.d/rustdesk.debian deleted file mode 100644 index 789ce8f7c..000000000 --- a/res/pam.d/rustdesk.debian +++ /dev/null @@ -1,5 +0,0 @@ -#%PAM-1.0 -@include common-auth -@include common-account -@include common-session -@include common-password diff --git a/res/pam.d/rustdesk.suse b/res/pam.d/rustdesk.suse deleted file mode 100644 index a7c7836ce..000000000 --- a/res/pam.d/rustdesk.suse +++ /dev/null @@ -1,5 +0,0 @@ -#%PAM-1.0 -auth include common-auth -account include common-account -session include common-session -password include common-password diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index ea7dd8a40..234c7beed 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,11 +1,11 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 gstreamer-plugins-base gstreamer-plugin-pipewire Recommends: libayatana-appindicator3-1 xdotool Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.so()(64bit), libfile_selector_linux_plugin.so()(64bit), libflutter_custom_cursor_plugin.so()(64bit), libflutter_linux_gtk.so()(64bit), libscreen_retriever_plugin.so()(64bit), libtray_manager_plugin.so()(64bit), liburl_launcher_linux_plugin.so()(64bit), libwindow_manager_plugin.so()(64bit), libwindow_size_plugin.so()(64bit), libtexture_rgba_renderer_plugin.so()(64bit) diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index 272148d91..95007c251 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,11 +1,11 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb libXfixes alsa-lib libva pam gstreamer1-plugins-base +Requires: gtk3 libxcb libXfixes alsa-lib libva gstreamer1-plugins-base Recommends: libayatana-appindicator-gtk3 libxdo Provides: libdesktop_drop_plugin.so()(64bit), libdesktop_multi_window_plugin.so()(64bit), libfile_selector_linux_plugin.so()(64bit), libflutter_custom_cursor_plugin.so()(64bit), libflutter_linux_gtk.so()(64bit), libscreen_retriever_plugin.so()(64bit), libtray_manager_plugin.so()(64bit), liburl_launcher_linux_plugin.so()(64bit), libwindow_manager_plugin.so()(64bit), libwindow_size_plugin.so()(64bit), libtexture_rgba_renderer_plugin.so()(64bit) diff --git a/res/rpm-suse.spec b/res/rpm-suse.spec index 14364eb77..b2f64d5b1 100644 --- a/res/rpm-suse.spec +++ b/res/rpm-suse.spec @@ -3,7 +3,7 @@ Version: 1.1.9 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 gstreamer-plugins-base gstreamer-plugin-pipewire Recommends: libayatana-appindicator3-1 xdotool # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ diff --git a/res/rpm.spec b/res/rpm.spec index 8aaf2508c..ef30dfba3 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,11 +1,11 @@ Name: rustdesk -Version: 1.4.9 +Version: 1.5.0 Release: 0 Summary: RPM package License: GPL-3.0 URL: https://rustdesk.com Vendor: rustdesk -Requires: gtk3 libxcb libXfixes alsa-lib libva2 pam gstreamer1-plugins-base +Requires: gtk3 libxcb libXfixes alsa-lib libva2 gstreamer1-plugins-base Recommends: libayatana-appindicator-gtk3 libxdo # https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ diff --git a/res/startwm.sh b/res/startwm.sh deleted file mode 100755 index 04e7a5a18..000000000 --- a/res/startwm.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash - -# This script is derived from https://github.com/neutrinolabs/xrdp/sesman/startwm.sh. - -# -# This script is an example. You might need to edit this script -# depending on your distro if it doesn't work for you. -# -# Uncomment the following line for debug: -# exec xterm - - -# Execution sequence for interactive login shell - pseudocode -# -# IF /etc/profile is readable THEN -# execute ~/.bash_profile -# END IF -# IF ~/.bash_profile is readable THEN -# execute ~/.bash_profile -# ELSE -# IF ~/.bash_login is readable THEN -# execute ~/.bash_login -# ELSE -# IF ~/.profile is readable THEN -# execute ~/.profile -# END IF -# END IF -# END IF -pre_start() -{ - if [ -r /etc/profile ]; then - . /etc/profile - fi - if [ -r ~/.bash_profile ]; then - . ~/.bash_profile - else - if [ -r ~/.bash_login ]; then - . ~/.bash_login - else - if [ -r ~/.profile ]; then - . ~/.profile - fi - fi - fi - return 0 -} - -# When logging out from the interactive shell, the execution sequence is: -# -# IF ~/.bash_logout exists THEN -# execute ~/.bash_logout -# END IF -post_start() -{ - if [ -r ~/.bash_logout ]; then - . ~/.bash_logout - fi - return 0 -} - -#start the window manager -wm_start() -{ - if [ -r /etc/default/locale ]; then - . /etc/default/locale - export LANG LANGUAGE - fi - - # debian - if [ -r /etc/X11/Xsession ]; then - pre_start - . /etc/X11/Xsession - post_start - exit 0 - fi - - # alpine - # Don't use /etc/X11/xinit/Xsession - it doesn't work - if [ -f /etc/alpine-release ]; then - if [ -f /etc/X11/xinit/xinitrc ]; then - pre_start - /etc/X11/xinit/xinitrc - post_start - else - echo "** xinit package isn't installed" >&2 - exit 1 - fi - fi - - # el - if [ -r /etc/X11/xinit/Xsession ]; then - pre_start - . /etc/X11/xinit/Xsession - post_start - exit 0 - fi - - # suse - if [ -r /etc/X11/xdm/Xsession ]; then - # since the following script run a user login shell, - # do not execute the pseudo login shell scripts - . /etc/X11/xdm/Xsession - exit 0 - elif [ -r /usr/etc/X11/xdm/Xsession ]; then - . /usr/etc/X11/xdm/Xsession - exit 0 - fi - - pre_start - xterm - post_start -} - -#. /etc/environment -#export PATH=$PATH -#export LANG=$LANG - -# change PATH to be what your environment needs usually what is in -# /etc/environment -#PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games" -#export PATH=$PATH - -# for PATH and LANG from /etc/environment -# pam will auto process the environment file if /etc/pam.d/xrdp-sesman -# includes -# auth required pam_env.so readenv=1 - -wm_start - -exit 1 diff --git a/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff b/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff new file mode 100644 index 000000000..37a7166cc --- /dev/null +++ b/res/vcpkg/aom/aom-uninitialized-pointer-3.9.1.diff @@ -0,0 +1,13 @@ +diff --git a/build/cmake/aom_configure.cmake b/build/cmake/aom_configure.cmake +index aaef2c310..5500ad4a3 100644 +--- a/build/cmake/aom_configure.cmake ++++ b/build/cmake/aom_configure.cmake +@@ -309,6 +309,8 @@ if(MSVC) + + # Disable MSVC warnings that suggest making code non-portable. + add_compiler_flag_if_supported("/wd4996") ++ # Disable MSVC warnings for potentially uninitialized local pointer variable. ++ add_compiler_flag_if_supported("/wd4703") + if(ENABLE_WERROR) + add_compiler_flag_if_supported("/WX") + endif() diff --git a/res/vcpkg/aom/aom-uninitialized-pointer.diff b/res/vcpkg/aom/aom-uninitialized-pointer.diff index 37a7166cc..0e8c12e21 100644 --- a/res/vcpkg/aom/aom-uninitialized-pointer.diff +++ b/res/vcpkg/aom/aom-uninitialized-pointer.diff @@ -1,7 +1,7 @@ -diff --git a/build/cmake/aom_configure.cmake b/build/cmake/aom_configure.cmake +diff --git a/cmake/aom_configure.cmake b/cmake/aom_configure.cmake index aaef2c310..5500ad4a3 100644 ---- a/build/cmake/aom_configure.cmake -+++ b/build/cmake/aom_configure.cmake +--- a/cmake/aom_configure.cmake ++++ b/cmake/aom_configure.cmake @@ -309,6 +309,8 @@ if(MSVC) # Disable MSVC warnings that suggest making code non-portable. diff --git a/res/vcpkg/aom/portfile.cmake b/res/vcpkg/aom/portfile.cmake index f7b1e3c43..502d31a7c 100644 --- a/res/vcpkg/aom/portfile.cmake +++ b/res/vcpkg/aom/portfile.cmake @@ -9,25 +9,24 @@ get_filename_component(PERL_PATH ${PERL} DIRECTORY) vcpkg_add_to_path(${PERL_PATH}) if(DEFINED ENV{USE_AOM_391}) + set(AOM_CONFIG_PATH "lib/cmake/aom") vcpkg_from_git( OUT_SOURCE_PATH SOURCE_PATH URL "https://aomedia.googlesource.com/aom" REF 8ad484f8a18ed1853c094e7d3a4e023b2a92df28 # 3.9.1 PATCHES - aom-uninitialized-pointer.diff + aom-uninitialized-pointer-3.9.1.diff aom-avx2.diff aom-install.diff ) else() + set(AOM_CONFIG_PATH "lib/cmake/AOM") vcpkg_from_git( OUT_SOURCE_PATH SOURCE_PATH URL "https://aomedia.googlesource.com/aom" - REF 10aece4157eb79315da205f39e19bf6ab3ee30d0 # 3.12.1 + REF 03087864cf4bea6abb0d28f95cf7843511413d8f # 3.14.1 PATCHES aom-uninitialized-pointer.diff - # aom-avx2.diff - # Can be dropped when https://bugs.chromium.org/p/aomedia/issues/detail?id=3029 is merged into the upstream - aom-install.diff ) endif() @@ -67,7 +66,7 @@ if(VCPKG_TARGET_IS_WINDOWS) endif() # Move cmake configs -vcpkg_cmake_config_fixup(CONFIG_PATH lib/cmake/${PORT}) +vcpkg_cmake_config_fixup(CONFIG_PATH ${AOM_CONFIG_PATH}) # Remove duplicate files file(REMOVE_RECURSE ${CURRENT_PACKAGES_DIR}/debug/include diff --git a/res/vcpkg/aom/vcpkg.json b/res/vcpkg/aom/vcpkg.json index 70a12d83e..8d69a88a3 100644 --- a/res/vcpkg/aom/vcpkg.json +++ b/res/vcpkg/aom/vcpkg.json @@ -1,6 +1,6 @@ { "name": "aom", - "version-semver": "3.12.1", + "version-semver": "3.14.1", "port-version": 0, "description": "AV1 codec library", "homepage": "https://aomedia.googlesource.com/aom", diff --git a/res/xorg.conf b/res/xorg.conf deleted file mode 100644 index fe1539995..000000000 --- a/res/xorg.conf +++ /dev/null @@ -1,30 +0,0 @@ -Section "Monitor" - Identifier "Dummy Monitor" - - # Default HorizSync 31.50 - 48.00 kHz - HorizSync 5.0 - 150.0 - # Default VertRefresh 50.00 - 70.00 Hz - VertRefresh 5.0 - 100.0 - - # Taken from https://www.xpra.org/xorg.conf - Modeline "1920x1080" 23.53 1920 1952 2040 2072 1080 1106 1108 1135 - Modeline "1280x720" 27.41 1280 1312 1416 1448 720 737 740 757 -EndSection - -Section "Device" - Identifier "Dummy VideoCard" - Driver "dummy" - # Default VideoRam 4096 - # (1920 * 1080 * 4) / 1024 = 8100 - VideoRam 8100 -EndSection - -Section "Screen" - Identifier "Dummy Screen" - Device "Dummy VideoCard" - Monitor "Dummy Monitor" - SubSection "Display" - Depth 24 - Modes "1920x1080" "1280x720" - EndSubSection -EndSection \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index 6f2347868..73cf466eb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -101,18 +101,6 @@ const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60); pub const VIDEO_QUEUE_SIZE: usize = 120; const MAX_DECODE_FAIL_COUNTER: usize = 3; -#[cfg(target_os = "linux")] -pub const LOGIN_MSG_DESKTOP_NOT_INITED: &str = "Desktop env is not inited"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY: &str = "Desktop session not ready"; -pub const LOGIN_MSG_DESKTOP_XSESSION_FAILED: &str = "Desktop xsession failed"; -pub const LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER: &str = "Desktop session another user login"; -pub const LOGIN_MSG_DESKTOP_XORG_NOT_FOUND: &str = "Desktop xorg not found"; -// ls /usr/share/xsessions/ -pub const LOGIN_MSG_DESKTOP_NO_DESKTOP: &str = "Desktop none"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY: &str = - "Desktop session not ready, password empty"; -pub const LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG: &str = - "Desktop session not ready, password wrong"; pub const LOGIN_MSG_PASSWORD_EMPTY: &str = "Empty Password"; pub const LOGIN_MSG_PASSWORD_WRONG: &str = "Wrong Password"; pub const LOGIN_MSG_2FA_WRONG: &str = "Wrong 2FA Code"; @@ -252,7 +240,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 @@ -2739,6 +2727,16 @@ impl LoginConfigHandler { } else { Bytes::new() }; + let os_login: MessageField = if self.conn_type == ConnType::TERMINAL { + Some(OSLogin { + username: os_username, + password: os_password, + ..Default::default() + }) + .into() + } else { + Default::default() + }; let mut lr = LoginRequest { username: pure_id, password: password.into(), @@ -2748,12 +2746,7 @@ impl LoginConfigHandler { option: self.get_option_message(true).into(), session_id: self.session_id, version: crate::VERSION.to_string(), - os_login: Some(OSLogin { - username: os_username, - password: os_password, - ..Default::default() - }) - .into(), + os_login, hwid, avatar, ..Default::default() @@ -3348,55 +3341,12 @@ struct LoginErrorMsgBox { lazy_static::lazy_static! { static ref LOGIN_ERROR_MAP: Arc> = { - use config::LINK_HEADLESS_LINUX_SUPPORT; let map = HashMap::from([(LOGIN_SCREEN_WAYLAND, LoginErrorMsgBox{ msgtype: "error", title: "Login Error", text: "Login screen using Wayland is not supported", link: "https://rustdesk.com/docs/en/manual/linux/#login-screen", try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LoginErrorMsgBox{ - msgtype: "session-login", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_XSESSION_FAILED, LoginErrorMsgBox{ - msgtype: "session-re-login", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "another_user_login_title_tip", - text: "another_user_login_text_tip", - link: "", - try_again: false, - }), (LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "xorg_not_found_title_tip", - text: "xorg_not_found_text_tip", - link: LINK_HEADLESS_LINUX_SUPPORT, - try_again: true, - }), (LOGIN_MSG_DESKTOP_NO_DESKTOP, LoginErrorMsgBox{ - msgtype: "info-nocancel", - title: "no_desktop_title_tip", - text: "no_desktop_text_tip", - link: LINK_HEADLESS_LINUX_SUPPORT, - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY, LoginErrorMsgBox{ - msgtype: "session-login-password", - title: "", - text: "", - link: "", - try_again: true, - }), (LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG, LoginErrorMsgBox{ - msgtype: "session-login-re-password", - title: "", - text: "", - link: "", - try_again: true, }), (LOGIN_MSG_NO_PASSWORD_ACCESS, LoginErrorMsgBox{ msgtype: "wait-remote-accept-nook", title: "Prompt", @@ -3455,9 +3405,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 +3462,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 +3474,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, } @@ -3500,7 +3498,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 @@ -3512,16 +3510,35 @@ pub async fn handle_hash( if let Some(uuid) = uuid { if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { let id = lc.read().unwrap().id.clone(); - if !consume_local_switch_sides_uuid(&id, &uuid).await { + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Consume, + ) + .await + { log::warn!("Ignored untrusted switch_uuid"); } else { lc.write().unwrap().allow_switch_back_once(); send_switch_login_request(lc.clone(), peer, uuid).await; lc.write().unwrap().password_source = Default::default(); - return; + return true; } } } + // Incoming-only may connect out solely for a verified switch-back; + // never fall through to password login, including on repeated hashes. + if config::is_incoming_only() { + interface.msgbox("error", "Connection Error", "Incoming only mode", ""); + let mut misc = Misc::new(); + misc.set_close_reason( + "Connection not allowed in incoming-only mode".to_owned(), + ); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + return false; + } } // last password let mut password = lc.read().unwrap().password.clone(); @@ -3584,7 +3601,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() { @@ -3598,18 +3615,9 @@ pub async fn handle_hash( hasher.finalize()[..].into() }; - let is_terminal = lc.read().unwrap().conn_type.eq(&ConnType::TERMINAL); - let (os_username, os_password) = if is_terminal { - ("".to_owned(), "".to_owned()) - } else { - ( - lc.read().unwrap().get_option("os-username"), - lc.read().unwrap().get_option("os-password"), - ) - }; - - send_login(lc.clone(), os_username, os_password, password, peer).await; + send_login(lc.clone(), String::new(), String::new(), password, peer).await; lc.write().unwrap().hash = hash; + true } #[inline] @@ -3737,7 +3745,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, @@ -4031,9 +4039,25 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b && !text.to_lowercase().contains("mismatch") && !text.to_lowercase().contains("manually") && !text.to_lowercase().contains("restricted") + && !text.to_lowercase().contains("incoming only") && !text.to_lowercase().contains("not allowed"))) } +#[cfg(test)] +mod retry_tests { + use super::check_if_retry; + + #[test] + fn incoming_only_error_is_not_retryable() { + assert!(!check_if_retry( + "error", + "Connection Error", + "Incoming only mode", + false, + )); + } +} + pub async fn hc_connection( feedback: i32, rendezvous_server: String, diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index c0eb7fb57..d7a4f570f 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 { @@ -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)) => { @@ -1433,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() { @@ -1466,6 +1462,18 @@ impl Remote { !lc.disable_clipboard.v && !lc.view_only.v }; if clipboard_allowed { + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + if self.handler.is_text_clipboard_required() + && crate::clipboard::is_sync_clipboard_between_sessions_enabled() + { + let mut msg = Message::new(); + msg.set_clipboard(cb.clone()); + let session_id = self.handler.lc.read().unwrap().session_id; + crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); + } #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Client); #[cfg(target_os = "ios")] @@ -1489,6 +1497,18 @@ impl Remote { !lc.disable_clipboard.v && !lc.view_only.v }; if clipboard_allowed { + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + if self.handler.is_text_clipboard_required() + && crate::clipboard::is_sync_clipboard_between_sessions_enabled() + { + let mut msg = Message::new(); + msg.set_multi_clipboards(_mcb.clone()); + let session_id = self.handler.lc.read().unwrap().session_id; + crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id); + } #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Client); #[cfg(target_os = "ios")] @@ -1984,26 +2004,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; @@ -2284,12 +2284,8 @@ impl Remote { .msgbox("custom-error", "Privacy mode", "Peer denied", ""); self.update_privacy_mode(impl_key, false); } - back_notification::PrivacyModeState::PrvOnFailedPlugin => { - self.handler - .msgbox("custom-error", "Privacy mode", "Please install plugins", ""); - self.update_privacy_mode(impl_key, false); - } - back_notification::PrivacyModeState::PrvOnFailed => { + back_notification::PrivacyModeState::PrvOnFailedPlugin + | back_notification::PrivacyModeState::PrvOnFailed => { self.handler.msgbox( "custom-error", "Privacy mode", diff --git a/src/clipboard.rs b/src/clipboard.rs index c7c01d6c4..2b6c8ba83 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -13,6 +13,17 @@ pub const CLIPBOARD_NAME: &'static str = "clipboard"; pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard"; pub const CLIPBOARD_INTERVAL: u64 = 333; +pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str = + "allow-sync-clipboard-between-sessions"; + +#[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))] +pub fn is_sync_clipboard_between_sessions_enabled() -> bool { + hbb_common::config::option2bool( + OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS, + &hbb_common::config::LocalConfig::get_option(OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS), + ) +} + // This format is used to store the flag in the clipboard. const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner"; diff --git a/src/common.rs b/src/common.rs index cd35433e0..648bc6b5c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -105,7 +105,7 @@ lazy_static::lazy_static! { // Is server logic running. The server code can invoked to run by the main process if --server is not running. static ref SERVER_RUNNING: Arc> = Default::default(); static ref IS_MAIN: bool = std::env::args().nth(1).map_or(true, |arg| !arg.starts_with("--")); - static ref IS_CM: bool = std::env::args().nth(1) == Some("--cm".to_owned()) || std::env::args().nth(1) == Some("--cm-no-ui".to_owned()); + static ref IS_CM: bool = std::env::args().nth(1) == Some("--cm".to_owned()); } pub struct SimpleCallOnReturn { @@ -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() { @@ -220,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool { } } +/// Android is scoped-storage only: the peer may never touch anything outside the app +/// workspace (`Config::get_home()`, i.e. the app-specific external files directory). +/// +/// Every peer supplied path must be validated with this before it reaches the +/// filesystem, for reads, writes, renames, creations and deletions alike. The path is +/// resolved to its canonical form (of the deepest existing ancestor, so paths that are +/// about to be created are handled too) so symlinks cannot escape the workspace. +/// +/// Only the `ReadDir` protocol action treats an empty path as the home directory. +/// Callers must opt in to that protocol-specific behavior with `allow_empty`. +#[cfg(target_os = "android")] +pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool { + use std::path::{Component, Path, PathBuf}; + + // Canonicalize the deepest existing ancestor and re-append the missing tail. + fn resolve(path: &Path) -> Option { + let mut tail: Vec = Vec::new(); + let mut base = path.to_path_buf(); + loop { + if let Ok(mut resolved) = base.canonicalize() { + while let Some(component) = tail.pop() { + resolved.push(component); + } + return Some(resolved); + } + tail.push(base.file_name()?.to_os_string()); + if !base.pop() { + return None; + } + } + } + + if path.is_empty() { + return allow_empty; + } + let path = Path::new(path); + // `..` is never needed by the protocol and would defeat the prefix check below. + if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { + return false; + } + let home = Config::get_home(); + let home = home.canonicalize().unwrap_or(home); + if home.as_os_str().is_empty() { + return false; + } + // `Path::starts_with` compares whole components, and is true for equal paths. + resolve(path).map_or(false, |target| target.starts_with(&home)) +} + +#[inline] +#[cfg(not(target_os = "android"))] +pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool { + true +} + #[inline] pub fn is_main() -> bool { *IS_MAIN @@ -1085,8 +1142,15 @@ fn get_api_server_(api: String, custom: String) -> String { #[inline] pub fn is_public(url: &str) -> bool { - let url = url.to_ascii_lowercase(); - url.contains("rustdesk.com/") || url.ends_with("rustdesk.com") + let parsed = url::Url::parse(url) + .ok() + .filter(|parsed| parsed.has_host()) + .or_else(|| url::Url::parse(&format!("http://{url}")).ok()); + let Some(host) = parsed.as_ref().and_then(url::Url::host_str) else { + return false; + }; + let host = host.strip_suffix('.').unwrap_or(host); + host == "rustdesk.com" || host.ends_with(".rustdesk.com") } pub fn get_udp_punch_enabled() -> bool { @@ -1405,6 +1469,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, @@ -2826,6 +2942,16 @@ mod tests { assert!(!is_public("rustdesk.comhello.com")); } + #[test] + fn test_is_public_matches_rustdesk_root_domain() { + assert!(is_public("rustdesk.com/")); + assert!(is_public("rustdesk.com:21117")); + assert!(is_public("api.rustdesk.com:21117")); + assert!(!is_public("hello-rustdesk.com")); + assert!(!is_public("api.rustdesk.com.evil.test")); + assert!(!is_public("https://rustdesk.com@evil.test")); + } + #[test] fn test_should_use_tcp_proxy_for_api_url() { assert!(should_use_tcp_proxy_for_api_url( diff --git a/src/core_main.rs b/src/core_main.rs index b20ecd92b..9b3d76f0a 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")] { @@ -716,14 +713,6 @@ pub fn core_main() -> Option> { // call connection manager to establish connections // meanwhile, return true to call flutter window to show control panel crate::ui_interface::start_option_status_sync(); - } else if args[0] == "--cm-no-ui" { - #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::ui_interface::start_option_status_sync(); - crate::flutter::connection_manager::start_cm_no_ui(); - } - return None; } else if args[0] == "--whiteboard" { #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -737,22 +726,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 +735,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 f6e3d3edd..f4971f18c 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(); @@ -1459,10 +1422,26 @@ pub fn update_file_clipboard_required() { #[cfg(not(target_os = "ios"))] pub fn send_clipboard_msg(msg: Message, _is_file: bool) { + send_clipboard_msg_impl(msg, _is_file, None); +} + +// `except_session_id` is the session the content came from, to avoid sending it back. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn send_clipboard_msg_to_other_sessions(msg: Message, except_session_id: u64) { + send_clipboard_msg_impl(msg, false, Some(except_session_id)); +} + +#[cfg(not(target_os = "ios"))] +fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Option) { for s in sessions::get_sessions() { if !s.is_default() { continue; } + if let Some(except_session_id) = except_session_id { + if s.lc.read().unwrap().session_id == except_session_id { + continue; + } + } #[cfg(feature = "unix-file-copy-paste")] if _is_file { if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version) @@ -1589,20 +1568,8 @@ pub mod connection_manager { } } - #[inline] #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn start_cm_no_ui() { - start_listen_ipc(false); - } - - #[inline] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn start_listen_ipc_thread() { - start_listen_ipc(true); - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn start_listen_ipc(new_thread: bool) { + fn start_listen_ipc() { use crate::ui_cm_interface::{start_ipc, ConnectionManager}; #[cfg(target_os = "linux")] @@ -1611,17 +1578,13 @@ pub mod connection_manager { let cm = ConnectionManager { ui_handler: FlutterHandler {}, }; - if new_thread { - std::thread::spawn(move || start_ipc(cm)); - } else { - start_ipc(cm); - } + std::thread::spawn(move || start_ipc(cm)); } #[inline] pub fn cm_init() { #[cfg(not(any(target_os = "android", target_os = "ios")))] - start_listen_ipc_thread(); + start_listen_ipc(); } #[cfg(target_os = "android")] @@ -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 9b73c4cd4..1528376ab 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, @@ -965,14 +962,6 @@ pub fn main_get_error() -> String { get_error() } -pub fn main_show_option(_key: String) -> SyncReturn { - #[cfg(target_os = "linux")] - if _key.eq(config::keys::OPTION_ALLOW_LINUX_HEADLESS) { - return SyncReturn(true); - } - SyncReturn(false) -} - pub fn main_set_option(key: String, value: String) { #[cfg(target_os = "android")] { @@ -2208,6 +2197,15 @@ pub fn cm_close_connection(conn_id: i32) { crate::ui_cm_interface::close(conn_id); } +/// The CM window closed. On Linux that is ambiguous - a logout closes it the same way a person +/// does - so it ends the session without the no-retry reason; elsewhere it is a plain close. +pub fn cm_close_connection_window(conn_id: i32) { + #[cfg(target_os = "linux")] + crate::ui_cm_interface::close_window(conn_id); + #[cfg(all(not(target_os = "linux"), not(target_os = "ios")))] + crate::ui_cm_interface::close(conn_id); +} + pub fn cm_remove_disconnected_connection(conn_id: i32) { #[cfg(not(any(target_os = "ios")))] crate::ui_cm_interface::remove(conn_id); @@ -2522,180 +2520,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)) } @@ -2840,6 +2664,14 @@ pub fn main_get_common(key: String) -> String { return crate::platform::linux::has_gnome_shortcuts_inhibitor_permission().to_string(); #[cfg(not(target_os = "linux"))] return false.to_string(); + } else if key == "gnome-monitor-layout-mode" { + #[cfg(target_os = "linux")] + return match crate::platform::linux::gnome_monitor_layout_mode() { + Some(mode) => mode.as_str().to_owned(), + None => String::new(), + }; + #[cfg(not(target_os = "linux"))] + return String::new(); } else if key == "permanent-password-set" { return ui_interface::is_permanent_password_set().to_string(); } else if key == "local-permanent-password-set" { @@ -3080,6 +2912,7 @@ pub mod server_side { env: JNIEnv, _class: JClass, app_dir: JString, + home_dir: JString, custom_client_config: JString, ) { log::debug!("startServer from jvm"); @@ -3087,6 +2920,9 @@ pub mod server_side { if let Ok(app_dir) = env.get_string(&app_dir) { *config::APP_DIR.write().unwrap() = app_dir.into(); } + if let Ok(home_dir) = env.get_string(&home_dir) { + *config::APP_HOME_DIR.write().unwrap() = home_dir.into(); + } if let Ok(custom_client_config) = env.get_string(&custom_client_config) { if !custom_client_config.is_empty() { let custom_client_config: String = custom_client_config.into(); diff --git a/src/ipc.rs b/src/ipc.rs index 188c2e467..804b89db6 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,10 +3,22 @@ mod ipc_auth; #[cfg(any(target_os = "linux", target_os = "macos"))] #[path = "ipc/fs.rs"] mod ipc_fs; +// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own +// module, declared the same way as the other pieces of this file, so the opt-in feature adds a +// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[path = "ipc/drm.rs"] +mod ipc_drm; +// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`, +// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two +// payload types. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo}; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::DrmConn; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::connect_drm; -#[cfg(all(feature = "flutter", feature = "plugin_framework"))] -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::plugin::ipc::Plugin; use crate::{ common::{is_server, CheckTestNatType}, privacy_mode, @@ -60,6 +72,9 @@ use ipc_fs::{ check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, should_scrub_parent_entries_after_check_pid, write_pid, }; +// Gated with the module that uses it, so a `drm`-less build does not carry an unused import. +#[cfg(all(target_os = "linux", feature = "drm"))] +use ipc_fs::remove_ipc_entry_via_secure_parent_fd; use parity_tokio_ipc::{ Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, }; @@ -294,6 +309,14 @@ pub enum DataPortableService { CmShowElevation(bool), } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum SwitchSidesUuidAction { + Check, + Consume, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum Data { @@ -369,7 +392,7 @@ pub enum Data { SwitchSidesRequest(String), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - SwitchSidesUuid(String, String, Option), + SwitchSidesUuid(String, String, SwitchSidesUuidAction, Option), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, @@ -378,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)), @@ -481,6 +501,60 @@ pub enum Data { ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + /// CM -> server: the connection manager's WINDOW went away, which is not the same event + /// as the operator disconnecting a peer. Linux only, and deliberately: there a session + /// logout closes every window, and the close arrives at the CM indistinguishable from a + /// person clicking it - measured on KDE, the CM gets no signal and logind still reports the + /// session active. So the ambiguous case ends the session WITHOUT the no-retry reason and + /// the peer is allowed to reconnect (landing on the greeter after a logout), while the + /// explicit Disconnect button keeps sending `Close` and kicking for good. + #[cfg(target_os = "linux")] + CmWindowClosed, + // --- 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")] @@ -987,20 +1061,24 @@ async fn handle(data: Data, stream: &mut Connection) { } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::SwitchSidesUuid(uuid, id, None) => { + Data::SwitchSidesUuid(uuid, id, action, None) => { let allowed = uuid .parse::() - .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .map(|uuid| match action { + SwitchSidesUuidAction::Check => { + crate::server::has_pending_switch_sides_uuid(&id, &uuid) + } + SwitchSidesUuidAction::Consume => { + crate::server::claim_pending_switch_sides_uuid(&id, &uuid) + } + }) .unwrap_or(false); allow_err!( stream - .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .send(&Data::SwitchSidesUuid(uuid, id, action, Some(allowed))) .await ); } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await, #[cfg(windows)] Data::ControlledSessionCount(_) => { allow_err!( diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 0dd43855e..89beef072 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 000000000..15e500e60 --- /dev/null +++ b/src/ipc/drm.rs @@ -0,0 +1,1800 @@ +// The DRM/KMS capture half of the `_drm` IPC channel: types, root-service producer, framing. + +use super::ipc_auth::active_uid_cached; +use super::*; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DrmDisplayInfo { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, + /// Render node of the GPU that EXPORTS this display's scanout; on a multi-GPU host auto-select + /// can bind a different GPU whose cross-vendor import then fails. Empty when the service cannot + /// name it: the consumer then auto-selects on a single-render-node host, and forces the CPU + /// path where there are several. + #[serde(default)] + pub render_node: String, + /// KMS card node (`/dev/dri/card*`) driving this display. crtc_ids are card-local, so the index + /// alone is ambiguous across cards. Empty = the single auto-detected device. + #[serde(default)] + pub device: String, +} + +/// Mirrors `scrap::drm_reader::drmtap_dmabuf_desc` except `dma_buf_fd` (never serializes — it rides +/// SCM_RIGHTS ancillary), and adds `buffer_id` (fb_id tagged with a per-connection epoch; no consumer reads it today) and `has_fd`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DmabufDesc { + pub buffer_id: u64, + pub width: u32, + pub height: u32, + pub format: u32, + pub modifier: u64, + /// KMS framebuffer id — libdrmtap's import-once cache key. 0 disables caching for this frame. + pub fb_id: u32, + /// Used entries in `offsets`/`pitches` (1..4); 0 is treated as 1. + pub num_planes: u32, + pub offsets: [u32; 4], + pub pitches: [u32; 4], + /// DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3). PQ triggers the HDR->SDR tone-map on convert. + pub hdr_eotf: u32, + pub hdr_max_nits: u32, + /// True: the fd rides this message's SCM_RIGHTS cmsg. False: import-once cache hit for `fb_id`. + pub has_fd: bool, +} + +pub(crate) fn drm_ipc_path() -> String { + let service_path = Config::ipc_path("_service"); + let dir = std::path::Path::new(&service_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/tmp")); + dir.join("ipc_drm").to_string_lossy().into_owned() +} + +pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType { + use std::os::fd::AsRawFd; + let path = drm_ipc_path(); + let stream = timeout(ms_timeout, tokio::net::UnixStream::connect(&path)).await??; + // The producer MUST be root: a non-root peer that won a socket-path race must not be trusted to + // supply the display list, frames and an arbitrary dma-buf fd. + if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { + bail!("drm: _drm producer is not root; refusing to consume"); + } + Ok(DrmConn::new(stream)) +} + +/// Bind the `_drm` listener 0666: connectable by any local uid, authorized in `handle_drm_conn`. +fn new_drm_listener() -> ResultType { + let path = drm_ipc_path(); + let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; + // NOT `std::fs::remove_file`: `unlink(2)` returns EISDIR against a directory-typed squatter and + // the bind then fails EADDRINUSE; the fd-based helper picks `AT_REMOVEDIR` (empty dirs only). + if let Err(err) = remove_ipc_entry_via_secure_parent_fd(&path) { + log::warn!("drm: could not clear a stale entry at {}: {}", &path, err); + } + let mut endpoint = Endpoint::new(path.clone()); + endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); + let incoming = endpoint.incoming()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { + std::fs::remove_file(&path).ok(); + err + })?; + log::info!("Started drm ipc server at path: {}", &path); + Ok(incoming) +} + +enum DrmProducerMsg { + /// Enumerated displays, sent once before any frame. + Displays(Vec), + /// Zero-copy path: descriptor + scanout fd; the `OwnedFd` is closed once the send has dup'd it. + Frame { + desc: DmabufDesc, + fd: Option, + }, + /// CPU-mapped fallback (packed BGRA): consumer has no convert context (`need_cpu`), or ENOTSUP. + FrameCpu { + width: u32, + height: u32, + data: Bytes, + }, + Cursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + colors: Vec, + }, +} + +struct DrmStopGuard(std::sync::Arc); +impl Drop for DrmStopGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +fn dup_to_drm_conn(stream: &Connection) -> ResultType { + let raw = stream.inner.get_ref().as_raw_fd(); + // F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec, and this process forks (the + // `loginctl` lookup), so an already-authorized `_drm` socket would leak into children. + let dup = unsafe { hbb_common::libc::fcntl(raw, hbb_common::libc::F_DUPFD_CLOEXEC, 0) }; + if dup < 0 { + return Err(std::io::Error::last_os_error().into()); + } + // SAFETY: `dup` is a freshly dup'd, owned fd for a connected SOCK_STREAM unix socket. + let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(dup) }; + std_stream.set_nonblocking(true)?; + let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; + Ok(DrmConn::new(tokio_stream)) +} + +static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Bumped only when a change altered `DRM_DISPLAY_CACHE`; Release orders it after the cache write. +static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Displays this reader serves, plus the identity (`device:connector`) of each undriven output. +fn drm_displays_from_reader( + reader: &mut scrap::drm_reader::DrmReader, + device: &str, +) -> (Vec, Vec) { + let render_node = reader.render_node().unwrap_or_default(); + let mut undriven = Vec::new(); + let displays: Vec = reader + .displays() + .into_iter() + // Only outputs bound to a CRTC: a CONNECTED-but-unbound connector enumerates with + // `crtc_id == 0`, and `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams ITS frames. + .filter(|d| { + if !d.active || d.crtc_id == 0 { + undriven.push(format!("{device}:{name}", name = d.name)); + return false; + } + true + }) + .map(|d| DrmDisplayInfo { + name: d.name, + crtc_id: d.crtc_id, + x: d.x, + y: d.y, + width: d.width, + height: d.height, + active: d.active, + render_node: render_node.clone(), + device: device.to_owned(), + }) + .collect(); + (displays, undriven) +} + +/// Active displays of every DRM device + the connected-but-undriven identities, from ONE look. +fn drm_enumerate_all_displays() -> (Vec, Vec) { + if let Some(devices) = scrap::drm_reader::list_devices() { + if devices.len() > 1 { + log::info!( + "drm: {} DRM devices: {}", + devices.len(), + devices + .iter() + .map(|d| format!( + "{} ({}, render {})", + d.path, + d.display_count, + if d.render_node.is_empty() { "none" } else { &d.render_node } + )) + .collect::>() + .join(", ") + ); + } + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut any_opened = false; + for dev in devices { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(&dev.path), 0) { + any_opened = true; + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, &dev.path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } else if dev.display_count == 0 { + log::debug!( + "drm: {} has no active display and did not open; cannot tell whether it has a \ + connected output that is merely switched off", + dev.path + ); + } + } + // Take this even when the list is EMPTY: the fallback re-keys identities under `device = ""`. + if any_opened { + return (all, undriven_total); + } + } + // Auto-detect alone is not enough: it picks a card that is SCANNING OUT. Measured on the T2 with + // the panel idle-disabled it binds card0 (the Touch Bar); the panel on card2 is invisible to it. + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut paths: Vec = match std::fs::read_dir("/dev/dri") { + Ok(rd) => rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("card") && n[4..].chars().all(|c| c.is_ascii_digit())) + }) + .collect(), + Err(err) => { + log::debug!("drm: cannot read /dev/dri to enumerate cards: {err}"); + Vec::new() + } + }; + // Deterministic order, so the display list does not depend on directory order. + paths.sort(); + let n_paths = paths.len(); + for p in paths { + let Some(path) = p.to_str() else { continue }; + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(path), 0) { + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } + } + log::info!( + "drm: enumerated /dev/dri directly ({} card path(s)): {} active display(s), {} connected \ + but undriven", + n_paths, + all.len(), + undriven_total.len() + ); + if all.is_empty() && undriven_total.is_empty() { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(None, 0) { + log::info!("drm: no card enumerated by path; falling back to the auto-detected reader"); + return drm_displays_from_reader(&mut r, ""); + } + } + (all, undriven_total) +} + +/// Connectors a wake did NOT bring back. SELF-REFUTING: an entry later seen DRIVEN is removed. +#[cfg(feature = "drm-wake")] +static DRM_WAKE_HOPELESS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(feature = "drm-wake")] +fn drm_wakeable_undriven(displays: &[DrmDisplayInfo], undriven: &[String]) -> Vec { + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !hopeless.is_empty() { + hopeless.retain(|id| { + let driven_now = displays + .iter() + .any(|d| format!("{}:{}", d.device, d.name) == *id); + if driven_now { + log::info!("drm: {id} is scanning out after all; treating it as wakeable again"); + } + !driven_now + }); + } + undriven + .iter() + .filter(|id| !hopeless.iter().any(|h| h == *id)) + .cloned() + .collect() +} + +#[cfg(feature = "drm-wake")] +static DRM_LAST_WAKE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(feature = "drm-wake")] +static DRM_WAKE_UNAVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Wake config key; `enable-` is load-bearing: an absent value reads as `!= "N"`, so it defaults ON. +#[cfg(feature = "drm-wake")] +const OPTION_ENABLE_DRM_DISPLAY_WAKE: &str = "enable-drm-display-wake"; + +#[cfg(feature = "drm-wake")] +const DRM_WAKE_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(20); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_DEVICE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_RECHECK_TOTAL: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_SETTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(5); + +/// Seconds since service start, monotonic: SystemTime would let a clock step re-open the wake gate. +#[cfg(feature = "drm-wake")] +fn drm_wake_clock_secs() -> u64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs() +} + +/// Look like user activity so the compositor re-enables an idle-DISABLED connector (until it does, +/// nothing scans out). Measured on a T2 greeter: one relative move restored a 2880x1800 scanout. +#[cfg(feature = "drm-wake")] +fn drm_wake_displays(reason: &str) -> bool { + use std::sync::atomic::Ordering; + + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return false; + } + let now = drm_wake_clock_secs(); + loop { + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last != 0 && now.saturating_sub(last) < DRM_WAKE_MIN_GAP.as_secs() { + log::debug!( + "drm: not waking displays ({reason}): a wake {}s ago is still recent", + now.saturating_sub(last) + ); + return false; + } + if DRM_LAST_WAKE + .compare_exchange(last, now.max(1), Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + break; + } + } + + // It has to look like a MOUSE: libinput ignores a device with a single relative axis and no + // buttons. Measured: REL_X + REL_Y + BTN_LEFT woke the panel; REL_X alone did not. + let mut axes = evdev::AttributeSet::::new(); + axes.insert(evdev::RelativeAxisType::REL_X); + axes.insert(evdev::RelativeAxisType::REL_Y); + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::Key::BTN_LEFT); + let built = evdev::uinput::VirtualDeviceBuilder::new() + .and_then(|b| b.name("RustDesk DRM display wake").with_relative_axes(&axes)) + .and_then(|b| b.with_keys(&keys)) + .and_then(|b| b.build()); + let mut dev = match built { + Ok(d) => d, + Err(err) => { + DRM_WAKE_UNAVAILABLE.store(true, Ordering::Relaxed); + log::warn!( + "drm: cannot wake displays ({reason}): no uinput device ({err}). A compositor that \ + disabled its outputs will keep them disabled, so there is no scanout to capture \ + until something else generates input. Note input injection needs uinput too, so \ + this session cannot control the host either." + ); + return false; + } + }; + + // A FRESH uinput device is not bound yet; events written before udev binds it are lost. Measured + // back to back: with this pause the panel went `disabled -> enabled`, without it it did not. + std::thread::sleep(DRM_WAKE_DEVICE_SETTLE); + + // +1 then -1: activity with zero net displacement. emit() appends the SYN_REPORT itself. + let step = |v: i32| { + evdev::InputEvent::new( + evdev::EventType::RELATIVE, + evdev::RelativeAxisType::REL_X.0, + v, + ) + }; + let ok = dev.emit(&[step(1)]).and_then(|_| { + std::thread::sleep(std::time::Duration::from_millis(120)); + dev.emit(&[step(-1)]) + }); + if let Err(err) = ok { + log::warn!("drm: display wake ({reason}) failed to emit: {err}"); + return false; + } + log::info!("drm: no display was scanning out ({reason}); asked the compositor to wake up"); + true +} + +#[cfg(not(feature = "drm-wake"))] +fn drm_enumerate_settled(reason: &str) -> Vec { + let (displays, undriven) = drm_enumerate_all_displays(); + if !undriven.is_empty() { + log::debug!( + "drm: {} connected display(s) have no CRTC ({reason}); this build has no display wake", + undriven.len() + ); + } + displays +} + +/// Wake build: wake an undriven display and WAIT for the settled topology. The wait applies to every +/// handshake whose wake may still be in flight, not only the one whose attempt won the rate limit. +#[cfg(feature = "drm-wake")] +fn drm_enumerate_settled(reason: &str) -> Vec { + use std::sync::atomic::Ordering; + + let (displays, undriven) = drm_enumerate_all_displays(); + if !hbb_common::config::Config::get_bool_option(OPTION_ENABLE_DRM_DISPLAY_WAKE) { + if !undriven.is_empty() { + log::info!( + "drm: {} connected display(s) have no CRTC ({reason}), but the display wake is \ + disabled by configuration ({OPTION_ENABLE_DRM_DISPLAY_WAKE}=N)", + undriven.len() + ); + } + return displays; + } + let wakeable = drm_wakeable_undriven(&displays, &undriven); + if wakeable.is_empty() { + return displays; + } + let fired = drm_wake_displays(&format!( + "{reason} and {n} connected display(s) had no CRTC", + n = wakeable.len() + )); + if !fired { + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return displays; + } + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last == 0 + || drm_wake_clock_secs().saturating_sub(last) > DRM_WAKE_SETTLE_WINDOW.as_secs() + { + return displays; + } + } + let before_len = displays.len(); + let deadline = std::time::Instant::now() + DRM_WAKE_RECHECK_TOTAL; + let mut cur = displays; + let mut cur_wakeable = wakeable; + while !cur_wakeable.is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(300)); + let (next, next_undriven) = drm_enumerate_all_displays(); + cur_wakeable = drm_wakeable_undriven(&next, &next_undriven); + cur = next; + } + if cur.len() > before_len { + log::info!( + "drm: {} display(s) came back after the wake ({} -> {}{})", + cur.len() - before_len, + before_len, + cur.len(), + if cur_wakeable.is_empty() { + String::new() + } else { + format!(", {} still undriven", cur_wakeable.len()) + } + ); + schedule_drm_cache_refresh(); + } + if fired && !cur_wakeable.is_empty() { + // Only the handshake that FIRED latches; a loser's baseline was taken mid-transition. + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for id in &cur_wakeable { + if !hopeless.iter().any(|h| h == id) { + hopeless.push(id.clone()); + } + } + log::info!( + "drm: the wake did not bring back {list}; not asking again for {these} until {it_is} \ + seen scanning out", + list = cur_wakeable.join(", "), + these = if cur_wakeable.len() == 1 { "it" } else { "them" }, + it_is = if cur_wakeable.len() == 1 { "it is" } else { "they are" }, + ); + } + cur +} + +/// The SINGLE writer of DRM_DISPLAY_CACHE (+ DRM_DISPLAY_GENERATION), off the caller's thread and +/// SINGLE-FLIGHT: a request arriving during a run coalesces into exactly one follow-up. +fn schedule_drm_cache_refresh() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RUNNING: AtomicBool = AtomicBool::new(false); + static PENDING: AtomicBool = AtomicBool::new(false); + // Ownership of RUNNING, released on every exit incl. unwind and failed spawn; re-taken mid-loop. + struct RefreshSlot(bool); + impl RefreshSlot { + fn release(&mut self) { + if self.0 { + self.0 = false; + RUNNING.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !RUNNING.swap(true, Ordering::AcqRel); + self.0 + } + } + impl Drop for RefreshSlot { + fn drop(&mut self) { + self.release(); + } + } + // Announce a refresh is wanted before trying to run, so an active worker is guaranteed to see it. + PENDING.store(true, Ordering::Release); + if RUNNING.swap(true, Ordering::AcqRel) { + return; // a worker is already active; it will observe PENDING and refresh again + } + let mut slot = RefreshSlot(true); + let spawned = std::thread::Builder::new() + .name("drm-cache-refresh".into()) + .spawn(move || loop { + PENDING.store(false, Ordering::Release); + let fresh = std::panic::catch_unwind(drm_enumerate_all_displays) + .unwrap_or_else(|_| { + log::error!("drm: display enumeration panicked; treating as no displays"); + (Vec::new(), Vec::new()) + }) + .0; + let changed = { + let mut cache = match DRM_DISPLAY_CACHE.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if *cache != fresh { + *cache = fresh; + true + } else { + false + } + }; + if changed { + DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release); + log::info!("drm: display cache refreshed (topology changed)"); + } + // Exit only if no request arrived during this enumeration. The re-check after releasing + // the slot closes the lost-wakeup window (a request that set PENDING just before it). + if !PENDING.load(Ordering::Acquire) { + slot.release(); + if !PENDING.load(Ordering::Acquire) { + break; + } + if !slot.retake() { + break; // another caller re-acquired the slot; it will handle the pending refresh + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the display-cache refresh worker: {err}"); + } +} + +fn uevent_is_drm_change(msg: &[u8]) -> bool { + let mut is_drm = false; + let mut is_change = false; + for rec in msg.split(|&b| b == 0) { + if rec == b"SUBSYSTEM=drm" { + is_drm = true; + } else if rec == b"ACTION=change" || rec == b"HOTPLUG=1" { + is_change = true; + } + } + is_drm && is_change +} + +/// Refresh the display cache on DRM hotplug uevents (raw NETLINK_KOBJECT_UEVENT, no libudev). +fn drm_udev_listener() { + use hbb_common::libc; + + let sock = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if sock < 0 { + log::info!( + "drm: udev uevent socket unavailable ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + let _owned = unsafe { OwnedFd::from_raw_fd(sock) }; + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as u16; + // Group 1 = kernel-originated uevents (udev re-broadcasts on group 2); pid 0 => kernel assigns. + addr.nl_groups = 1; + let rc = unsafe { + libc::bind( + sock, + &addr as *const libc::sockaddr_nl as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc < 0 { + log::info!( + "drm: udev uevent bind failed ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + log::info!("drm: udev DRM-uevent listener started"); + let mut buf = [0u8; 8192]; + loop { + // recvmsg, not recv: a local process could UNICAST a spoofed uevent to this root listener. + let mut src: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() }; + mhdr.msg_name = &mut src as *mut libc::sockaddr_nl as *mut libc::c_void; + mhdr.msg_namelen = std::mem::size_of::() as libc::socklen_t; + mhdr.msg_iov = &mut iov; + mhdr.msg_iovlen = 1; + let n = unsafe { libc::recvmsg(sock, &mut mhdr, 0) }; + if n <= 0 { + let err = std::io::Error::last_os_error(); + if n < 0 && err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + log::info!("drm: udev uevent recv ended ({err}); hotplug refresh stopped"); + break; + } + if (mhdr.msg_namelen as usize) < std::mem::size_of::() + || src.nl_pid != 0 + || src.nl_groups == 0 + { + continue; + } + if !uevent_is_drm_change(&buf[..n as usize]) { + continue; + } + schedule_drm_cache_refresh(); + } +} + +fn drm_prewarm() { + // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: + // "x11" 0.8 s into a boot on a Wayland host). `is_x11_for_drm()` is that path minus the + // greeter blind spot, which a login screen never leaves. + const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); + const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); + let waited = std::time::Instant::now(); + while crate::platform::linux::is_x11_for_drm() { + if waited.elapsed() >= PREWARM_SESSION_BUDGET { + log::info!( + "drm: session still reads as X11 after {:?}; skipping the pre-warm \ + (the _drm listener still runs)", + PREWARM_SESSION_BUDGET + ); + return; + } + std::thread::sleep(PREWARM_SESSION_RECHECK); + } + let t = std::time::Instant::now(); + schedule_drm_cache_refresh(); + match scrap::drm_reader::DrmReader::open(None, 0) { + Some(mut r) => { + // grab_desc(), not grab(): exports an fd without loading libEGL into the root service. + if let Ok((fd, _desc)) = r.grab_desc() { + drop(fd); // close the warm-up fd; we only wanted to prime the device/import path + } + log::info!("drm: pre-warm framebuffer primed in {:?}", t.elapsed()); + } + None => log::info!("drm: pre-warm skipped (no reader; cache refresh requested)"), + } +} + +/// Capture producer in the ROOT `--service`: one task per consumer, reader on a worker thread. +#[tokio::main(flavor = "current_thread")] +pub async fn start_drm() { + match new_drm_listener() { + Ok(mut incoming) => { + if let Err(err) = std::thread::Builder::new() + .name("drm-prewarm".into()) + .spawn(drm_prewarm) + { + log::warn!("drm: could not spawn the pre-warm thread ({err}); skipping the warmup"); + } + if let Err(err) = std::thread::Builder::new() + .name("drm-udev".into()) + .spawn(drm_udev_listener) + { + log::warn!( + "drm: could not spawn the udev listener ({err}); a mid-session topology change \ + will not be pushed, and consumers pick it up on their next handshake" + ); + } + loop { + match incoming.next().await { + Some(Ok(stream)) => { + tokio::spawn(async move { + if let Err(err) = handle_drm_conn(Connection::new(stream)).await { + log::info!("drm ipc connection ended: {}", err); + } + }); + } + Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), + None => { + log::error!("drm ipc listener stream ended; stopping drm producer"); + break; + } + } + } + } + Err(err) => { + log::error!("Failed to start drm ipc server: {}", err); + } + } +} + +const MAX_DRM_CONNS: usize = 8; + +fn drm_conn_admitted(prev_count: usize) -> bool { + prev_count < MAX_DRM_CONNS +} + +const MAX_DRM_AUTH_IN_FLIGHT: usize = 4; + +fn drm_auth_admitted(prev_in_flight: usize) -> bool { + prev_in_flight < MAX_DRM_AUTH_IN_FLIGHT +} + +fn drm_peer_authorized(peer_uid: Option, active_uid: Option) -> bool { + match peer_uid { + Some(0) => true, + Some(uid) => active_uid == Some(uid), + None => false, + } +} + +/// Handle one `_drm` consumer: a private worker thread owns the `!Send` reader; this task forwards. +async fn handle_drm_conn(stream: Connection) -> ResultType<()> { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + // World-connectable socket, so the peer MUST be authorized here (this listener bypasses the + // generic `start()` accept loop). On the blocking pool: a cache miss forks `loginctl`. + static DRM_AUTH_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); + struct DrmAuthGuard; + impl Drop for DrmAuthGuard { + fn drop(&mut self) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_auth_admitted(DRM_AUTH_IN_FLIGHT.fetch_add(1, Ordering::SeqCst)) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + // Deliberately `debug`, not `warn`: this is reachable by any local uid, so a level that + // reaches the service log on every attempt is an unbounded log-write primitive for that peer. + log::debug!("drm: too many _drm authorizations in flight; dropping this connection"); + return Ok(()); + } + let auth_guard = DrmAuthGuard; + let (stream, authorized) = tokio::task::spawn_blocking(move || { + let ok = authorize_service_scoped_ipc_connection(&stream, "_drm"); + (stream, ok) + }) + .await?; + drop(auth_guard); + if !authorized { + // Deliberately no log here: the call above already reports it -- the uid mismatch through + // `log_rejected_service_connection`, throttled to one line per 5 s, and the executable + // mismatch as a plain warn. A second, unthrottled warn here would be the same unbounded + // log-write primitive. + return Ok(()); + } + + static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); + struct DrmConnGuard; + impl Drop for DrmConnGuard { + fn drop(&mut self) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_conn_admitted(DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst)) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); + return Ok(()); + } + let _conn_guard = DrmConnGuard; + + // Re-authorized per frame below: DRM/KMS capture is NOT session-scoped, so unless a stream stops + // when the active session changes the outgoing user's --server keeps receiving the incoming + // user's screen (and the greeter in between). + let peer_uid = stream.peer_uid(); + + let mut conn = dup_to_drm_conn(&stream)?; + drop(stream); + + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<(String, u32, bool)>(); + let stop = Arc::new(AtomicBool::new(false)); + let _stop_guard = DrmStopGuard(stop.clone()); + let worker_stop = stop.clone(); + let frames_gated = Arc::new(AtomicBool::new(false)); + let worker_gate = frames_gated.clone(); + std::thread::Builder::new() + .name("drm-capture".into()) + .spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop, worker_gate)) + .map_err(|err| anyhow::anyhow!("could not spawn the drm capture worker: {err}"))?; + + let displays = match frame_rx.recv().await { + Some(DrmProducerMsg::Displays(d)) => d, + _ => { + log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); + return Ok(()); + } + }; + conn.send_msg(&Data::DrmDisplayList(displays.clone()), None).await?; + + let (display_idx, need_cpu) = match conn.recv_msg_timeout2(10_000).await { + Some(Ok((Data::DrmStart { display, need_cpu }, _fd))) => (display, need_cpu), + Some(Ok((_, _fd))) => { + log::info!("drm: peer sent something other than DrmStart in the handshake; closing"); + return Ok(()); + } + Some(Err(e)) => return Err(e), + None => return Ok(()), // timed out: client never chose a display + }; + // Reject crtc 0: `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams the WRONG monitor. + let selected = usize::try_from(display_idx) + .ok() + .and_then(|i| displays.get(i)); + let target_crtc = selected.map(|d| d.crtc_id).unwrap_or(0); + let target_device = selected.map(|d| d.device.clone()).unwrap_or_default(); + if target_crtc == 0 { + log::warn!( + "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" + ); + return Ok(()); + } + if crtc_tx.send((target_device, target_crtc, need_cpu)).is_err() { + return Ok(()); + } + + let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + const DRM_FRAME_CREDIT: i32 = 2; + let mut credit: i32 = DRM_FRAME_CREDIT; + let mut credit_since = std::time::Instant::now(); + let mut held_frame: Option = None; + loop { + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + // While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog: a + // consumer that stops acking without closing the socket would otherwise hold this connection, + // its worker thread and the privileged DRM context open indefinitely. + const CREDIT_STALL: std::time::Duration = std::time::Duration::from_secs(5); + if credit > 0 { + credit_since = std::time::Instant::now(); + } else if credit_since.elapsed() > CREDIT_STALL { + log::info!("drm: consumer has not acked for {CREDIT_STALL:?}; closing _drm connection"); + break; + } + // This must NOT also require that a frame is already held: those grabs keep the held frame + // fresh (latest-wins below), so gating on "held" would pin whatever frame was in hand when + // credit ran out and ship it stale once the ack lands. + frames_gated.store(credit <= 0, Ordering::Relaxed); + let first: Option = if held_frame.is_some() && credit > 0 { + frame_rx.try_recv().ok() + } else if credit <= 0 { + const CREDIT_POLL: std::time::Duration = std::time::Duration::from_secs(1); + let waited = tokio::time::timeout(CREDIT_POLL, async { + tokio::select! { + biased; + r = conn.wait_readable() => r.map(|_| None), + m = frame_rx.recv() => Ok(Some(m)), + } + }) + .await; + match waited { + Err(_) => None, + Ok(Err(err)) => return Err(err), + Ok(Ok(None)) => None, + Ok(Ok(Some(None))) => break, + Ok(Ok(Some(Some(m)))) => Some(m), + } + } else { + match frame_rx.recv().await { + Some(f) => Some(f), + None => break, + } + }; + // Re-authorize per frame with the CACHE-ONLY active uid: a fresh lookup forks `loginctl` and + // would stall every stream on this single-threaded runtime. A miss is fail-closed for a non-root peer + // (root stays authorized; see `drm_peer_authorized`). + let peer_ok = drm_peer_authorized(peer_uid, active_uid_cached()); + if !peer_ok { + log::warn!("drm: _drm peer no longer matches the active session (or it is unknown); closing"); + break; + } + let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + if gen != seen_gen { + seen_gen = gen; + let fresh = DRM_DISPLAY_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // Send even an EMPTY list, or the consumer keeps advertising removed displays. + conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; + } + let mut latest_frame: Option = held_frame.take(); + let mut msg = first.or_else(|| frame_rx.try_recv().ok()); + while let Some(m) = msg.take() { + match m { + f @ (DrmProducerMsg::Frame { .. } | DrmProducerMsg::FrameCpu { .. }) => { + latest_frame = Some(f); + } + DrmProducerMsg::Cursor { + id, + width, + height, + hotx, + hoty, + colors, + } => { + conn.send_msg( + &Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + }, + None, + ) + .await?; + conn.send_raw(Bytes::from(colors)).await?; + } + DrmProducerMsg::Displays(_) => {} + } + msg = frame_rx.try_recv().ok(); + } + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + if credit <= 0 { + held_frame = latest_frame; + continue; + } + match latest_frame { + Some(DrmProducerMsg::Frame { mut desc, fd }) => { + // Every exported frame carries its fd: the kernel can recycle an fb_id onto another + // buffer with the same geometry/modifier and this side cannot see the dma-buf inode + // that would tell the difference, so eliding it can serve a stale EGLImage. libdrmtap's + // import cache keys on fb_id AND inode, and can only re-import when handed a real fd. + let send_fd = fd.is_some(); + desc.has_fd = send_fd; + let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; + conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; + credit -= 1; // one frame in flight until the consumer acks it + // `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it + // into the peer), which bounds our fd usage to ~1 in flight per frame. + } + Some(DrmProducerMsg::FrameCpu { + width, + height, + data, + }) => { + conn.send_msg(&Data::DrmFrame { width, height }, None).await?; + conn.send_raw(data).await?; + credit -= 1; // one frame in flight until the consumer acks it + } + _ => {} + } + } + Ok(()) +} + +fn drm_capture_worker( + frame_tx: tokio::sync::mpsc::Sender, + crtc_rx: std::sync::mpsc::Receiver<(String, u32, bool)>, + stop: std::sync::Arc, + frames_gated: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + use std::time::Duration; + const FRAME_INTERVAL: Duration = Duration::from_millis(33); + // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5 s). + const MAX_STALLED: u32 = 150; + + let t_conn = std::time::Instant::now(); + + // Enumerate FRESH rather than serve the cache: a cached display may no longer be driven. + let displays = drm_enumerate_settled("a consumer connected"); + if frame_tx + .blocking_send(DrmProducerMsg::Displays(displays)) + .is_err() + { + return; + } + + let (target_device, target_crtc, need_cpu) = match crtc_rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + let device_arg = if target_device.is_empty() { + None + } else { + Some(target_device.as_str()) + }; + let t_open = std::time::Instant::now(); + let mut reader = match scrap::drm_reader::DrmReader::open(device_arg, target_crtc) { + Some(r) => r, + None => { + log::warn!( + "drm: failed to open crtc {target_crtc} on {}; closing _drm connection", + if target_device.is_empty() { "auto" } else { &target_device } + ); + schedule_drm_cache_refresh(); + return; + } + }; + schedule_drm_cache_refresh(); + log::debug!( + "drm: capture reader for crtc {target_crtc} opened in {:?}", + t_open.elapsed() + ); + + static DRM_CONN_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed); + + let mut use_dmabuf = !need_cpu; + + let mut last_cursor_id: u64 = 0; + let mut stalled: u32 = 0; + let mut logged_first = false; + while !stop.load(Ordering::Relaxed) { + let grabbed: Option> = if frames_gated.load(Ordering::Relaxed) + { + // `stalled` is left untouched because the device is healthy -- the task bounds this + // state itself (CREDIT_STALL) since our watchdog cannot advance. + None + } else if use_dmabuf { + Some(match reader.grab_desc() { + Ok((fd, d)) => Ok(DrmProducerMsg::Frame { + desc: DmabufDesc { + buffer_id: (d.fb_id as u64) | ((conn_epoch as u64) << 32), + width: d.width, + height: d.height, + format: d.format, + modifier: d.modifier, + fb_id: d.fb_id, + num_planes: d.num_planes, + offsets: d.offsets, + pitches: d.pitches, + hdr_eotf: d.hdr_eotf, + hdr_max_nits: d.hdr_max_nits, + has_fd: true, // every exported frame carries its fd; see the send below + }, + fd: Some(fd), + }), + Err(err) => Err(err), + }) + } else { + Some(match reader.grab() { + Ok((buf, w, h)) => Ok(DrmProducerMsg::FrameCpu { + width: w as u32, + height: h as u32, + data: Bytes::copy_from_slice(buf), + }), + Err(err) => Err(err), + }) + }; + match grabbed { + None => {} + Some(Ok(msg)) => { + stalled = 0; + if !logged_first { + logged_first = true; + log::debug!( + "drm: first frame for crtc {target_crtc} in {:?} ({} path)", + t_conn.elapsed(), + if use_dmabuf { "dma-buf" } else { "cpu" } + ); + } + if frame_tx.blocking_send(msg).is_err() { + break; + } + } + Some(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => { + stalled += 1; + if stalled > MAX_STALLED { + log::info!("drm: capture stalled (no frame); closing _drm connection"); + break; + } + std::thread::sleep(FRAME_INTERVAL); + continue; + } + Some(Err(err)) if use_dmabuf && err.kind() == std::io::ErrorKind::Unsupported => { + log::warn!( + "drm: grab_desc unsupported ({err}); switching to CPU-mapped fallback for this connection" + ); + use_dmabuf = false; + logged_first = false; + // The stall counter measured the abandoned path; give the fallback the whole budget. + stalled = 0; + continue; + } + Some(Err(err)) => { + log::warn!("drm: capture error: {err}; closing _drm connection"); + break; + } + } + + // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). + if let Some(c) = reader.cursor() { + if c.id != last_cursor_id { + last_cursor_id = c.id; + if frame_tx + .blocking_send(DrmProducerMsg::Cursor { + id: c.id, + width: c.width, + height: c.height, + hotx: c.hotx, + hoty: c.hoty, + colors: c.colors, + }) + .is_err() + { + break; + } + } + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + +/// Ancillary-fd transport for `_drm`: `Framed`/`BytesCodec` cannot carry an SCM_RIGHTS cmsg, so the +/// messages and raw bodies use a 4-byte big-endian length + payload, with any fd bound to the first + /// byte. The reverse-direction frame acks are bare bytes, not framed. +pub(crate) struct DrmConn { + stream: tokio::net::UnixStream, + read_buf: Vec, + /// Set once the current read consumed a byte: a spurious `readable()` vs a mid-frame stall. + consumed: bool, +} + +const MAX_DRM_JSON_BYTES: usize = 8 * 1024 * 1024; +const DRM_BODY_TIMEOUT_MS: u64 = 5_000; +const DRM_SEND_TIMEOUT_MS: u64 = 5_000; + +const MAX_DRM_RAW_BYTES: usize = 512 * 1024 * 1024; +/// `CMSG_SPACE(sizeof(int))` is 24 bytes on our targets; 64 gives headroom and the `align(8)` +/// matches `cmsghdr` alignment. +const DRM_CMSG_CAP: usize = 64; + +/// Aligned storage for the SCM_RIGHTS control buffer (`msg_control` must be `cmsghdr`-aligned). +#[repr(align(8))] +struct DrmCmsgBuf([u8; DRM_CMSG_CAP]); + +/// One non-blocking `sendmsg`; the cmsg is attached ONLY when a fd is present (-1 fails the call). +/// SAFETY: `fd` a valid open socket fd, `buf` a readable slice, `pass_fd` (if any) a valid open fd. +unsafe fn drm_sendmsg(fd: RawFd, buf: &[u8], pass_fd: Option) -> std::io::Result { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + if let Some(sfd) = pass_fd { + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + if cmsg.is_null() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: CMSG_FIRSTHDR null", + )); + } + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + let sfd_c: libc::c_int = sfd; + std::ptr::copy_nonoverlapping( + &sfd_c as *const libc::c_int as *const u8, + libc::CMSG_DATA(cmsg), + std::mem::size_of::(), + ); + } + let n = libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL); + if n < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +/// One non-blocking `recvmsg`: keeps at most one SCM_RIGHTS fd (surplus closed), rejects MSG_CTRUNC. +/// SAFETY: `fd` must be a valid open socket fd; `buf` a valid writable slice. +unsafe fn drm_recvmsg(fd: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Option)> { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = cbuf.0.len() as _; + let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + let mut got: Option = None; + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(cmsg); + let hdr = libc::CMSG_LEN(0) as usize; + let payload = ((*cmsg).cmsg_len as usize).saturating_sub(hdr); + let count = payload / std::mem::size_of::(); + for i in 0..count { + let mut rawfd: libc::c_int = -1; + std::ptr::copy_nonoverlapping( + data.add(i * std::mem::size_of::()), + &mut rawfd as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + if rawfd >= 0 { + let owned = OwnedFd::from_raw_fd(rawfd); + if got.is_none() { + got = Some(owned); + } // else: surplus fd, dropped here -> closed + } + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + drop(got); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: truncated SCM_RIGHTS control message (MSG_CTRUNC)", + )); + } + Ok((n as usize, got)) +} + +async fn drm_write_all( + stream: &tokio::net::UnixStream, + mut buf: &[u8], + mut pass_fd: Option, +) -> ResultType<()> { + // ONE deadline for the whole write: arming it per readiness wait lets a dripping peer re-arm it. + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + while !buf.is_empty() { + match tokio::time::timeout_at(deadline, stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: peer did not accept the remaining {} byte(s) within {DRM_SEND_TIMEOUT_MS}ms; closing", + buf.len() + ), + } + let raw = stream.as_raw_fd(); + let chunk = buf; + let fd_now = pass_fd; + match stream.try_io(tokio::io::Interest::WRITABLE, || unsafe { + drm_sendmsg(raw, chunk, fd_now) + }) { + Ok(0) => bail!("drm: socket write returned 0 (peer closed)"), + Ok(n) => { + pass_fd = None; // ancillary delivered with these bytes; do not re-send it + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + +async fn drm_send_frame( + stream: &tokio::net::UnixStream, + payload: &[u8], + pass_fd: Option, +) -> ResultType<()> { + if payload.len() > u32::MAX as usize { + bail!("drm: frame too large ({} bytes)", payload.len()); + } + let prefix = (payload.len() as u32).to_be_bytes(); + drm_write_all(stream, &prefix, pass_fd).await?; + drm_write_all(stream, payload, None).await?; + Ok(()) +} + +async fn drm_read_full( + stream: &tokio::net::UnixStream, + buf: &mut [u8], + want_cmsg: bool, + progress: &mut bool, +) -> ResultType> { + use hbb_common::libc; + let mut off = 0usize; + let mut got: Option = None; + while off < buf.len() { + stream.readable().await?; + let raw = stream.as_raw_fd(); + let use_cmsg = want_cmsg && got.is_none(); + let n = { + let dst: &mut [u8] = &mut buf[off..]; + match stream.try_io(tokio::io::Interest::READABLE, move || unsafe { + if use_cmsg { + drm_recvmsg(raw, dst) + } else { + let m = libc::read(raw, dst.as_mut_ptr() as *mut libc::c_void, dst.len()); + if m < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok((m as usize, None)) + } + } + }) { + Ok((0, _fd)) => bail!("drm: socket closed by peer"), + Ok((m, fd)) => { + if let Some(f) = fd { + if got.is_none() { + got = Some(f); + } + } + m + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + }; + // Any byte off the socket commits us to this frame: a cancellation cannot be re-polled. + if n > 0 { + *progress = true; + } + off += n; + } + Ok(got) +} + +impl DrmConn { + pub fn new(stream: tokio::net::UnixStream) -> Self { + Self { + stream, + read_buf: Vec::new(), + consumed: false, + } + } + + pub async fn send_msg(&mut self, data: &Data, fd: Option>) -> ResultType<()> { + let payload = serde_json::to_vec(data)?; + let pass_fd = fd.map(|f| f.as_raw_fd()); + drm_send_frame(&self.stream, &payload, pass_fd).await + } + + pub async fn send_frame_ack(&self) -> ResultType<()> { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + loop { + match tokio::time::timeout_at(deadline, self.stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: _drm frame-ack was not accepted within {DRM_SEND_TIMEOUT_MS}ms; closing" + ), + } + match self.stream.try_write(&[1u8]) { + Ok(n) if n > 0 => return Ok(()), + Ok(_) => bail!("drm: _drm frame-ack write returned 0 (peer closed)"), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + } + + pub fn drain_frame_acks(&self, credit: &mut i32, max: i32) -> ResultType<()> { + let mut buf = [0u8; 64]; + // BOUNDED: "until WouldBlock" is the peer's promise; a continuous writer would pin us. + const MAX_ACK_READS: usize = 64; + for _ in 0..MAX_ACK_READS { + match self.stream.try_read(&mut buf) { + Ok(0) => bail!("drm: _drm frame-ack peer closed"), + Ok(n) => { + *credit = (*credit + n as i32).min(max); + if *credit >= max { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + pub async fn wait_readable(&self) -> ResultType<()> { + self.stream.readable().await?; + Ok(()) + } + + pub async fn recv_msg(&mut self) -> ResultType<(Data, Option)> { + self.consumed = false; + let mut prefix = [0u8; 4]; + let fd = drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed).await?; + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_JSON_BYTES { + bail!("drm: message length {len} exceeds cap {MAX_DRM_JSON_BYTES}"); + } + if self.read_buf.len() < len { + self.read_buf.resize(len, 0); + } + drm_read_full(&self.stream, &mut self.read_buf[..len], false, &mut self.consumed).await?; + let data: Data = serde_json::from_slice(&self.read_buf[..len])?; + Ok((data, fd)) + } + + /// Cancel-safe timeout wrapper around `recv_msg`. `None` = nothing consumed, so re-polling is + /// safe; past the first byte the frame is committed and an overrun is a hard error. + pub async fn recv_msg_timeout2( + &mut self, + ms_timeout: u64, + ) -> Option)>> { + let ready = timeout(ms_timeout, self.stream.readable()).await; + match ready { + Err(_) => None, // no frame started: clean boundary, caller re-checks `stop` + Ok(Err(e)) => Some(Err(e.into())), + Ok(Ok(())) => match timeout(ms_timeout, self.recv_msg()).await { + Ok(res) => Some(res), + Err(_) if self.consumed => Some(Err(anyhow::anyhow!( + "drm: frame body stalled past {ms_timeout}ms after first byte; closing" + ))), + Err(_) => None, + }, + } + } + + pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { + drm_send_frame(&self.stream, &data, None).await + } + + pub async fn next_raw_into(&mut self, out: &mut Vec) -> ResultType<()> { + match timeout(DRM_BODY_TIMEOUT_MS, self.next_raw_into_unbounded(out)).await { + Ok(res) => res, + Err(_) => bail!( + "drm: raw body did not arrive within {DRM_BODY_TIMEOUT_MS}ms of its header; closing" + ), + } + } + + async fn next_raw_into_unbounded(&mut self, out: &mut Vec) -> ResultType<()> { + let mut prefix = [0u8; 4]; + if drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed) + .await? + .is_some() + { + log::warn!("drm: unexpected fd on a raw-body frame; dropping"); + } + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_RAW_BYTES { + bail!("drm: raw body length {len} exceeds cap {MAX_DRM_RAW_BYTES}"); + } + out.resize(len, 0); + drm_read_full(&self.stream, &mut out[..], false, &mut self.consumed).await?; + Ok(()) + } +} + +#[cfg(test)] +mod drm_conn_tests { + use super::*; + use hbb_common::libc; + use hbb_common::tokio::{self, io::AsyncWriteExt}; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + + // Added to the wire later: an older peer's message must still decode. + #[test] + fn drm_display_info_decodes_without_render_node() { + let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0, + "width":3840,"height":2160,"active":true}"#; + let info: DrmDisplayInfo = + serde_json::from_str(legacy).expect("a pre-render_node payload must still decode"); + assert_eq!(info.name, "DP-1"); + assert_eq!(info.crtc_id, 386); + assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node"); + assert!(info.device.is_empty(), "missing device means auto-detect"); + + let current = DrmDisplayInfo { + name: "DP-1".to_owned(), + crtc_id: 386, + x: 0, + y: 0, + width: 3840, + height: 2160, + active: true, + render_node: "/dev/dri/renderD129".to_owned(), + device: "/dev/dri/card2".to_owned(), + }; + let wire = serde_json::to_vec(¤t).unwrap(); + let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap(); + assert_eq!(back, current); + } + + fn pipe() -> (OwnedFd, OwnedFd) { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } + } + + unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { + let mut iov = libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }; + let fdbytes = fds.len() * std::mem::size_of::(); + let space = libc::CMSG_SPACE(fdbytes as u32) as usize; + let mut cbuf = vec![0u8; space]; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = space as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; + std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); + libc::sendmsg(sock, &msg, 0) + } + + #[tokio::test] + async fn roundtrip_msg_no_fd() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) + .await + .unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 1920, + height: 1080 + } + )); + assert!(fd.is_none(), "no fd was sent, none must be reported"); + } + + #[tokio::test] + async fn roundtrip_msg_with_fd_identity() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) + .await + .unwrap(); + let (_data, fd) = rx.recv_msg().await.unwrap(); + let recv_fd = fd.expect("an fd was attached, it must be received"); + let sentinel = [0xABu8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); + } + + #[tokio::test] + async fn roundtrip_raw_body() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let body = Bytes::from(vec![7u8; 5000]); + tx.send_raw(body.clone()).await.unwrap(); + let mut got = Vec::new(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &body[..]); + let short = Bytes::from(vec![9u8; 10]); + tx.send_raw(short.clone()).await.unwrap(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &short[..]); + } + + #[tokio::test] + async fn rejects_oversized_length_prefix() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); + a.write_all(&bogus).await.unwrap(); + let err = rx + .recv_msg() + .await + .err() + .expect("a length past the cap must be rejected"); + assert!( + err.to_string().contains("exceeds cap"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_body_that_never_arrives_times_out() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + a.write_all(&10u32.to_be_bytes()).await.unwrap(); + let mut got = Vec::new(); + let err = rx + .next_raw_into(&mut got) + .await + .err() + .expect("a body that never arrives must time out"); + assert!( + err.to_string().contains("did not arrive"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_dripping_peer_cannot_re_arm_the_send_deadline() { + use tokio::io::AsyncReadExt; + let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap(); + let payload = vec![0u8; 32 * 1024 * 1024]; + // Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms. + let drip = tokio::spawn(async move { + let mut sink = vec![0u8; 64 * 1024]; + loop { + if reader.read(&mut sink).await.unwrap_or(0) == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }); + let started = std::time::Instant::now(); + let outcome = tokio::time::timeout( + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4), + drm_write_all(&writer, &payload, None), + ) + .await; + drip.abort(); + let inner = outcome.expect( + "the send deadline did not fire: the budget is being re-armed per readiness wait", + ); + let err = inner.err().expect("a dripping peer must not complete the write"); + assert!( + err.to_string().contains("did not accept the remaining"), + "unexpected error: {err}" + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3), + "took {:?}, which is not the send deadline firing", + started.elapsed() + ); + } + + #[tokio::test] + async fn surplus_fds_keep_only_the_first() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + let (rd2, _wr2) = pipe(); + let payload = serde_json::to_vec(&Data::DrmFrame { + width: 8, + height: 8, + }) + .unwrap(); + let prefix = (payload.len() as u32).to_be_bytes(); + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + a.write_all(&payload).await.unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 8, + height: 8 + } + )); + let kept = fd.expect("the first surplus fd must be kept"); + let sentinel = [0x5Au8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); + } + + // 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC. + #[tokio::test] + async fn rejects_truncated_control_message() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, _wr) = pipe(); + let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); + let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); + let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + let err = rx + .recv_msg() + .await + .err() + .expect("a truncated control message must be rejected"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("truncat") || msg.contains("ctrunc"), + "unexpected error: {err}" + ); + } + + #[test] + fn peer_uid_from_fd_reads_socket_peer() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + let euid = unsafe { libc::geteuid() }; + assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); + } + + #[test] + fn drm_peer_authorized_matrix() { + assert!(drm_peer_authorized(Some(0), Some(1000))); + assert!(drm_peer_authorized(Some(0), None)); + assert!(drm_peer_authorized(Some(1000), Some(1000))); + assert!(!drm_peer_authorized(Some(1000), Some(1001))); + assert!(!drm_peer_authorized(Some(1000), None)); + assert!(!drm_peer_authorized(None, Some(1000))); + assert!(!drm_peer_authorized(None, None)); + } + + #[test] + fn accept_time_exe_match_accepts_only_our_own_executable() { + let me = std::process::id(); + assert!( + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(me), "_drm").is_ok(), + "the test process must match its own executable" + ); + + let mut other = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("/bin/sleep should be spawnable in the test environment"); + // Until the child finishes exec'ing, /proc//exe still points at OUR binary. + let ours = std::fs::read_link(format!("/proc/{me}/exe")).ok(); + let peer_link = format!("/proc/{}/exe", other.id()); + let mut exec_done = false; + for _ in 0..200 { + match std::fs::read_link(&peer_link) { + Ok(p) if Some(&p) != ours.as_ref() => { + exec_done = true; + break; + } + _ => std::thread::sleep(std::time::Duration::from_millis(10)), + } + } + let res = if exec_done { + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(other.id()), "_drm") + } else { + Err(anyhow::anyhow!("child never exec'd; nothing was tested")) + }; + let _ = other.kill(); + let _ = other.wait(); + assert!(exec_done, "the spawned child never exec'd, so the negative case was not exercised"); + assert!( + res.is_err(), + "a peer running another executable must be rejected, got {res:?}" + ); + + assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err()); + } + + #[test] + fn drm_conn_admission_bound() { + assert!(drm_conn_admitted(0)); + assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot + assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected + assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected + } + + #[test] + fn drm_auth_admission_bound() { + assert!(drm_auth_admitted(0)); + assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected + assert!( + MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS, + "the pre-auth bound must not be looser than the connection cap" + ); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index e0157f3a9..2472ecc83 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/lang.rs b/src/lang.rs index 077c6232a..8001b6e71 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -45,6 +45,7 @@ mod th; mod tr; mod tw; mod uk; +mod ur; mod vi; mod ta; mod ge; @@ -80,6 +81,7 @@ pub const LANGS: &[(&str, &str)] = &[ ("ko", "한국어"), ("kz", "Қазақ"), ("uk", "Українська"), + ("ur", "اردو"), ("fa", "فارسی"), ("ca", "Català"), ("el", "Ελληνικά"), @@ -208,6 +210,7 @@ pub fn translate_locale(name: String, locale: &str) -> String { "be" => be::T.deref(), "he" => he::T.deref(), "hr" => hr::T.deref(), + "ur" => ur::T.deref(), "sc" => sc::T.deref(), "ta" => ta::T.deref(), "ge" => ge::T.deref(), diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 2189648d9..da12b2017 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", "مطفئ"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "هذا الملف مطابق لملف موجود عن القرين."), ("show_monitors_tip", "عرض الشاشات في شريط الادوات"), ("View Mode", "وضع العرض"), - ("login_linux_tip", "تحتاج الى تسجيل الدخول حساب لينكس البعيد وتفعيل جلسة سطح مكتب X"), ("verify_rustdesk_password_tip", "تحقق من كلمة مرور RustDesk"), - ("remember_account_tip", "تذكر هذا الحساب"), - ("os_account_desk_tip", "هذا الحساب مستخدم لتسجيل الدخول الى سطح المكتب البعيد وتفعيل الجلسة"), - ("OS Account", "حساب نظام التشغيل"), - ("another_user_login_title_tip", "مستخدم اخر مسجل دخول حاليا"), - ("another_user_login_text_tip", "قطع الاتصال"), - ("xorg_not_found_title_tip", "Xorg غير موجود"), - ("xorg_not_found_text_tip", "الرجاء تثبيت Xorg"), - ("no_desktop_title_tip", "لا يتوفر سطح مكتب"), - ("no_desktop_text_tip", "الرجاء تثبيت سطح مكتب GNOME"), ("No need to elevate", "لا حاجة للارتقاء"), ("System Sound", "صوت النظام"), ("Default", "الافتراضي"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "البصمة"), ("Copy Fingerprint", "نسخ البصمة"), ("no fingerprints", "لا توجد بصمات اصابع"), - ("Select a peer", "اختر قرين"), - ("Select peers", "اختر الاقران"), - ("Plugins", "الاضافات"), - ("Uninstall", "الغاء التثبيت"), ("Update", "تحديث"), - ("Enable", "تفعيل"), - ("Disable", "تعطيل"), - ("Options", "الخيارات"), ("resolution_original_tip", "الدقة الأصلية"), ("resolution_fit_local_tip", "تناسب الدقة المحلية"), ("resolution_custom_tip", "دقة مخصصة"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"), ("screenshot-action-tip", "إجراء لقطة الشاشة"), ("Save as", "حفظ باسم"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "نسخ إلى الحافظة"), ("Enable remote printer", "تمكين الطابعة عن بُعد"), ("Downloading {}", "جارٍ تنزيل {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "متابعة"), + ("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."), + ("Lock canvas", "قفل اللوحة"), + ("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"), + ("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index ac302f3af..2c012fb64 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", "Выключаны"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файл ідэнтычны файлу абанента"), ("show_monitors_tip", "Паказваць маніторы на панэлі інструментаў"), ("View Mode", "Рэжым прагляду"), - ("login_linux_tip", "Каб уключыць сеанс працоўнага стала X, трэба ўвайсці ў аддалены ўліковы запіс Linux."), ("verify_rustdesk_password_tip", "Пацвердзіць пароль RustDesk"), - ("remember_account_tip", "Запомніць гэты ўліковы запіс"), - ("os_account_desk_tip", "Гэты ўліковы запіс выкарыстоўваецца для ўваходу ў аддаленую аперацыйную сістэму і ўключэння сеанса працоўнага стала ў рэжыме headless."), - ("OS Account", "Акаўнт АС"), - ("another_user_login_title_tip", "Іншы карыстальнік ужо ўвайшоў у сістэму"), - ("another_user_login_text_tip", "Адключыць"), - ("xorg_not_found_title_tip", "Xorg не знойдзены"), - ("xorg_not_found_text_tip", "Усталюйце Xorg"), - ("no_desktop_title_tip", "Няма даступных працоўных сталоў"), - ("no_desktop_text_tip", "Усталюйце GNOME Desktop"), ("No need to elevate", "Павышэнне правоў не патрабуецца"), ("System Sound", "Сістэмны гук"), ("Default", "Стандартна"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Адбітак"), ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Select a peer", "Выберыце абанента"), - ("Select peers", "Выберыце абанентаў"), - ("Plugins", "Убудовы"), - ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), - ("Enable", "Уключыць"), - ("Disable", "Адключыць"), - ("Options", "Параметры"), ("resolution_original_tip", "Арыгінальная раздзяляльнасць"), ("resolution_fit_local_tip", "Супадзенне з лакальнай раздзяляльнасцю"), ("resolution_custom_tip", "Карыстацкая раздзяляльнасць"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."), ("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."), ("Save as", "Захаваць у файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скапіяваць у буфер абмену"), ("Enable remote printer", "Выкарыстоўваць аддалены прынтар"), ("Downloading {}", "Ідзе спампоўванне {}"), @@ -773,7 +758,10 @@ 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.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."), + ("Lock canvas", "Заблакіраваць палатно"), + ("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"), + ("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index c339270c0..4104846ee 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", "Изкключен"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файлът съвпада с този от другата страна."), ("show_monitors_tip", "Показване на мониторите в лентата с инструменти"), ("View Mode", "Режим на изглед"), - ("login_linux_tip", "Трябва да влезете в отдалечен Linux акаунт, за да активирате X сесия на работния плот"), ("verify_rustdesk_password_tip", "Проверете RustDesk паролата"), - ("remember_account_tip", "Запомнете този акаунт"), - ("os_account_desk_tip", "Този акаунт се използва за влизане в отдалечената операционна система и позволява на десктоп сесия без моинитор"), - ("OS Account", "Профил в операционната система"), - ("another_user_login_title_tip", "Друг потребител вече е влязъл"), - ("another_user_login_text_tip", "Прекъснете връзката"), - ("xorg_not_found_title_tip", "Xorg не е намерен"), - ("xorg_not_found_text_tip", "Моля, инсталирайте Xorg"), - ("no_desktop_title_tip", "Няма наличен работен плот"), - ("no_desktop_text_tip", "Моля, инсталирайте работен плот GNOME"), ("No need to elevate", "Няма нужда за повишаване на права"), ("System Sound", "Системен звук"), ("Default", "По подразбиране"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Пръстов отпечатък"), ("Copy Fingerprint", "Копиране на пръстов отпечатък"), ("no fingerprints", "Няма пръстови отпечатъци"), - ("Select a peer", "Избери отдалечена страна"), - ("Select peers", "Избери отдалечени страни"), - ("Plugins", "Плъгини"), - ("Uninstall", "Премахни"), ("Update", "Обновяване"), - ("Enable", "Позволяване"), - ("Disable", "Забрана"), - ("Options", "Настроики"), ("resolution_original_tip", "Оригинална разделителна способност"), ("resolution_fit_local_tip", "Приспособяване към тукашната разделителна способност"), ("resolution_custom_tip", "Разделителна способност по свой избор"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."), ("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."), ("Save as", "Запазване като"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копиране в клипборда"), ("Enable remote printer", "Позволяване на отдалечен принтер"), ("Downloading {}", "Изтегляне на {}"), @@ -773,7 +758,10 @@ 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.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."), + ("Lock canvas", "Заключване на платното"), + ("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"), + ("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index d3b0ae7e0..8d73f523b 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Aquest fitxer és idèntic al del client."), ("show_monitors_tip", "Mostra les pantalles a la barra d'eines"), ("View Mode", "Mode espectador"), - ("login_linux_tip", "És necessari que inicieu prèviament sessió amb un entorn d'escriptori x11 habilitat"), ("verify_rustdesk_password_tip", "Verifica la contrasenya del RustDesk"), - ("remember_account_tip", "Recorda aquest compte"), - ("os_account_desk_tip", "S'utilitza aquest compte per iniciar la sessió al sistema remot i habilitar el mode sense cap pantalla connectada"), - ("OS Account", "Compte d'usuari"), - ("another_user_login_title_tip", "Altre usuari ha iniciat ja una sessió"), - ("another_user_login_text_tip", "Desconnecta"), - ("xorg_not_found_title_tip", "No s'ha trobat l'entorn Xorg"), - ("xorg_not_found_text_tip", "Instal·leu el Xorg"), - ("no_desktop_title_tip", "Cap escriptori disponible"), - ("no_desktop_text_tip", "Instal·leu l'entorn d'escriptori GNOME"), ("No need to elevate", "No calen permisos ampliats"), ("System Sound", "So del sistema"), ("Default", "per defecte"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empremta"), ("Copy Fingerprint", "Copia l'empremta"), ("no fingerprints", "Cap empremta"), - ("Select a peer", "Seleccioneu un client"), - ("Select peers", "Seleccioneu els clients"), - ("Plugins", "Complements"), - ("Uninstall", "Desinstal·la"), ("Update", "Actualitza"), - ("Enable", "Activa"), - ("Disable", "Desactiva"), - ("Options", "Opcions"), ("resolution_original_tip", "Resolució original"), ("resolution_fit_local_tip", "Ajusta la resolució local"), ("resolution_custom_tip", "Resolució personalitzada"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."), ("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."), ("Save as", "Anomena i desa"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia al porta-retalls"), ("Enable remote printer", "Habilita l'impressora remota"), ("Downloading {}", "Descarregant {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Continua"), + ("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."), + ("Lock canvas", "Bloca el llenç"), + ("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"), + ("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 7423cceb3..8a819fd7e 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", "退出"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "此文件与对方的一致"), ("show_monitors_tip", "在工具栏上显示监视器"), ("View Mode", "浏览模式"), - ("login_linux_tip", "登录被控端的 Linux 账户,才能启用 X 桌面"), ("verify_rustdesk_password_tip", "验证 RustDesk 密码"), - ("remember_account_tip", "记住此账户"), - ("os_account_desk_tip", "在无显示器的环境下,此账户用于登录被控系统,并启用桌面"), - ("OS Account", "系统账户"), - ("another_user_login_title_tip", "其他用户已登录"), - ("another_user_login_text_tip", "断开"), - ("xorg_not_found_title_tip", "Xorg 未安装"), - ("xorg_not_found_text_tip", "请安装 Xorg"), - ("no_desktop_title_tip", "desktop 未安装"), - ("no_desktop_text_tip", "请安装 desktop"), ("No need to elevate", "无需提升权限"), ("System Sound", "系统音频"), ("Default", "默认"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指纹"), ("Copy Fingerprint", "复制指纹"), ("no fingerprints", "没有指纹"), - ("Select a peer", "选择一个被控端"), - ("Select peers", "选择被控"), - ("Plugins", "插件"), - ("Uninstall", "卸载"), ("Update", "更新"), - ("Enable", "启用"), - ("Disable", "禁用"), - ("Options", "选项"), ("resolution_original_tip", "原始分辨率"), ("resolution_fit_local_tip", "适应本地分辨率"), ("resolution_custom_tip", "自定义分辨率"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"), ("screenshot-action-tip", "请选择如何继续截屏。"), ("Save as", "另存为"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "复制到剪贴板"), ("Enable remote printer", "启用远程打印机"), ("Downloading {}", "正在下载 {}"), @@ -774,6 +759,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_whitelist_caveat_tip", "ID 由对端客户端上报,白名单用于减少暴露面,不能替代密码或 2FA"), ("whitelist_cidr_tip", "支持 CIDR 写法,例如 192.168.1.0/24"), ("Continue", "继续"), - ("Browser didn't open? Use the url below to sign in.", ""), + ("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"), + ("Lock canvas", "锁定画布"), + ("Sync clipboard between sessions", "在会话间同步剪贴板"), + ("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index abd4e60aa..71b151e41 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ý"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tento soubor je totožný se souborem partnera."), ("show_monitors_tip", "Zobrazit monitory na panelu nástrojů"), ("View Mode", "Režim zobrazení"), - ("login_linux_tip", "Chcete-li povolit relaci plochy X, musíte se přihlásit ke vzdálenému účtu systému Linux."), ("verify_rustdesk_password_tip", "Ověření hesla RustDesk"), - ("remember_account_tip", "Zapamatovat si tento účet"), - ("os_account_desk_tip", "Tento účet se používá k přihlášení do vzdáleného operačního systému a k povolení relace plochy v režimu headless."), - ("OS Account", "Účet operačního systému"), - ("another_user_login_title_tip", "Další uživatel je již přihlášen"), - ("another_user_login_text_tip", "Odpojit"), - ("xorg_not_found_title_tip", "Xorg nebyl nalezen"), - ("xorg_not_found_text_tip", "Prosím, nainstalujte Xorg"), - ("no_desktop_title_tip", "Není k dispozici žádná plocha"), - ("no_desktop_text_tip", "Nainstalujte si prosím prostředí GNOME"), ("No need to elevate", "Není třeba navýšení"), ("System Sound", "Systémový zvuk"), ("Default", "Výchozí"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisk"), ("Copy Fingerprint", "Kopírovat otisk"), ("no fingerprints", "žádný otisk"), - ("Select a peer", "Výběr protistrany"), - ("Select peers", "Vybrat protistrany"), - ("Plugins", "Pluginy"), - ("Uninstall", "Odinstalovat"), ("Update", "Aktualizovat"), - ("Enable", "Povolit"), - ("Disable", "Zakázat"), - ("Options", "Možnosti"), ("resolution_original_tip", "Původní rozlišení"), ("resolution_fit_local_tip", "Přizpůsobit místní rozlišení"), ("resolution_custom_tip", "Vlastní rozlišení"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."), ("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."), ("Save as", "Uložit jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovat do schránky"), ("Enable remote printer", "Povolit vzdálenou tiskárnu"), ("Downloading {}", "Stahuje se {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Pokračovat"), + ("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."), + ("Lock canvas", "Zamknout zobrazení"), + ("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"), + ("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 0ecab9098..3ba116072 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"), @@ -336,24 +335,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Scale adaptive", "Adaptiv skalering"), ("General", "Generelt"), ("Security", "Sikkerhed"), - ("Theme", "Thema"), - ("Dark Theme", "Mørk Tema"), - ("Light Theme", "Lys Tema"), + ("Theme", "Tema"), + ("Dark Theme", "Mørkt tema"), + ("Light Theme", "Lyst tema"), ("Dark", "Mørk"), ("Light", "Lys"), - ("Follow System", "Følg System"), + ("Follow System", "Følg system"), ("Enable hardware codec", "Aktivér hardware-codec"), ("Unlock Security Settings", "Lås op for sikkerhedsindstillinger"), ("Enable audio", "Aktivér Lyd"), - ("Unlock Network Settings", "Lås op for Netværksindstillinger"), + ("Unlock Network Settings", "Lås op for netværksindstillinger"), ("Server", "Server"), - ("Direct IP Access", "Direkte IP Adgang"), + ("Direct IP Access", "Direkte IP-adgang"), ("Proxy", "Proxy"), ("Apply", "Anvend"), ("Disconnect all devices?", "Afbryd alle enheder?"), ("Clear", "Nulstil"), ("Audio Input Device", "Lydindgangsenhed"), - ("Use IP Whitelisting", "Brug IP Whitelisting"), + ("Use IP Whitelisting", "Brug IP-hvidlistning"), ("Network", "Netværk"), ("Pin Toolbar", "Fastgør værktøjslinjen"), ("Unpin Toolbar", "Frigiv værktøjslinjen"), @@ -378,10 +377,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Full Access", "Fuld adgang"), ("Screen Share", "Skærmdeling"), ("ubuntu-21-04-required", "Wayland kræver Ubuntu version 21.04 eller nyere."), - ("wayland-requires-higher-linux-version", "Wayland kræver en højere version af Linux distro. Prøv venligst X11 desktop eller skift dit OS."), + ("wayland-requires-higher-linux-version", "Wayland kræver en højere version af Linux-distro. Prøv venligst X11-desktoppen eller skift dit OS."), ("xdp-portal-unavailable", "Skærmoptagelse via Wayland mislykkedes. XDG Desktop Portal kan være gået ned eller er utilgængelig. Prøv at genstarte den med `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "JumpLink"), - ("Please Select the screen to be shared(Operate on the peer side).", "Vælg venligst den skærm, der skal deles (Betjen på modtagersiden)."), + ("Please Select the screen to be shared(Operate on the peer side).", "Vælg venligst den skærm, der skal deles (betjen på modtagersiden)."), ("Show RustDesk", "Vis RustDesk"), ("This PC", "Denne PC"), ("or", "eller"), @@ -393,32 +392,32 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Please wait for the remote side to accept your session request...", "Vent venligst på at fjernklienten accepterer din sessionsforespørgsel..."), ("One-time Password", "Engangskode"), ("Use one-time password", "Brug engangskode"), - ("One-time password length", "Engangskode længde"), + ("One-time password length", "Længde af engangskode"), ("Request access to your device", "Efterspørg adgang til din enhed"), ("Hide connection management window", "Skjul forbindelseshåndteringsvindue"), ("hide_cm_tip", "Tillad at skjule, hvis der kun forbindes ved brug af midlertidige og permanente adgangskoder"), - ("wayland_experiment_tip", "Wayland understøttelse er stadigvæk under udvikling. Hvis du har brug for ubemandet adgang, bedes du bruge X11."), + ("wayland_experiment_tip", "Wayland-understøttelse er stadigvæk under udvikling. Hvis du har brug for ubemandet adgang, bedes du bruge X11."), ("Right click to select tabs", "Højreklik for at vælge faner"), ("Skipped", "Sprunget over"), ("Add to address book", "Tilføj til adressebog"), ("Group", "Gruppe"), ("Search", "Søg"), ("Closed manually by web console", "Lukket ned manuelt af webkonsollen"), - ("Local keyboard type", "Lokal tastatur type"), - ("Select local keyboard type", "Vælg lokal tastatur type"), - ("software_render_tip", "Hvis du bruger et Nvidia grafikkort på Linux, og fjernskrivebordsvinduet lukker ned med det samme efter forbindelsen er oprettet, kan det hjælpe at skifte til Nouveau open-source driveren, og aktivere software rendering. Et genstart af RustDesk er nødvendigt."), - ("Always use software rendering", "Brug altid software rendering"), + ("Local keyboard type", "Type af lokalt tastatur"), + ("Select local keyboard type", "Vælg typen af lokalt tastatur"), + ("software_render_tip", "Hvis du bruger et Nvidia-grafikkort på Linux, og fjernskrivebordsvinduet lukker ned med det samme efter forbindelsen er oprettet, kan det hjælpe at skifte til Nouveau open source-driveren, og aktivere software-rendering. En genstart af RustDesk er nødvendig."), + ("Always use software rendering", "Brug altid software-rendering"), ("config_input", "For at styre fjernskrivebordet med tastaturet, skal du give Rustdesk rettigheder til at optage tastetryk"), ("config_microphone", "For at tale sammen over fjernstyring, skal du give RustDesk rettigheder til at optage lyd"), ("request_elevation_tip", "Du kan også spørge om elevationsrettigheder, hvis der er nogen i nærheden af fjernenheden."), ("Wait", "Vent"), ("Elevation Error", "Elevationsfejl"), - ("Ask the remote user for authentication", "Spørg fjernbrugeren for godkendelse"), + ("Ask the remote user for authentication", "Bed fjernbrugeren om at godkende"), ("Choose this if the remote account is administrator", "Vælg dette hvis fjernbrugeren er en administrator"), ("Transmit the username and password of administrator", "Send brugernavnet og adgangskoden på administratoren"), - ("still_click_uac_tip", "Kræver stadigvæk at fjernbrugeren skal trykke OK på UAC vinduet ved kørsel af RustDesk."), - ("Request Elevation", "Efterspørger elevation"), - ("wait_accept_uac_tip", "Vent venligst på at fjernbrugeren accepterer UAC dialog forespørgslen."), + ("still_click_uac_tip", "Kræver stadigvæk at fjernbrugeren skal trykke OK på UAC-vinduet ved kørsel af RustDesk."), + ("Request Elevation", "Efterspørg elevation"), + ("wait_accept_uac_tip", "Vent venligst på at fjernbrugeren accepterer UAC-dialog-forespørgslen."), ("Elevate successfully", "Elevation lykkedes"), ("uppercase", "store bogstaver"), ("lowercase", "små bogstaver"), @@ -442,13 +441,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Voice call", "Stemmeopkald"), ("Text chat", "Tekstchat"), ("Stop voice call", "Stop stemmeopkald"), - ("relay_hint_tip", "Det kan ske, at det ikke er muligt at forbinde direkte; du kan forsøge at forbinde via en relay-server. Derudover, hvis du ønsker at bruge en relay-server på dit første forsøg, kan du tilføje \"/r\" efter ID'et, eller bruge valgmuligheden \"Forbind altid via relay-server\" i fanen for seneste sessioner, hvis den findes."), + ("relay_hint_tip", "Det er måske ikke muligt at forbinde direkte; du kan forsøge at forbinde via en relay-server. Hvis du ønsker at bruge en relay-server på dit første forsøg, kan du tilføje \"/r\" efter ID'et, eller bruge valgmuligheden \"Forbind altid via relay-server\" i fanen for seneste sessioner, hvis den findes."), ("Reconnect", "Genopret"), ("Codec", "Codec"), ("Resolution", "Opløsning"), ("No transfers in progress", "Ingen overførsler i gang"), - ("Set one-time password length", "Sæt engangsadgangskode længde"), - ("RDP Settings", "RDP indstillinger"), + ("Set one-time password length", "Sæt længde af engangsadgangskode"), + ("RDP Settings", "RDP-indstillinger"), ("Sort by", "Sortér efter"), ("New Connection", "Ny forbindelse"), ("Restore", "Gendan"), @@ -456,26 +455,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Maximize", "Maksimér"), ("Your Device", "Din enhed"), ("empty_recent_tip", "Ups, ingen seneste sessioner!\nTid til at oprette en ny."), - ("empty_favorite_tip", "Ingen yndlings modparter endnu?\nLad os finde én at forbinde til, og tilføje den til dine favoritter!"), - ("empty_lan_tip", "Åh nej, det ser ud til, at vi ikke kunne finde nogen modparter endnu."), - ("empty_address_book_tip", "Åh nej, det ser ud til at der ikke er nogle modparter der er tilføjet til din adressebog."), - ("Empty Username", "Tom brugernavn"), + ("empty_favorite_tip", "Ingen yndlingsmodparter endnu?\nLad os finde én at forbinde til, og tilføje den til dine favoritter!"), + ("empty_lan_tip", "Åh nej, det ser ud til, at vi ikke har kunnet finde nogen modparter endnu."), + ("empty_address_book_tip", "Åh nej, det ser ud til at der ikke er nogen modparter, der er tilføjet til din adressebog."), + ("Empty Username", "Tomt brugernavn"), ("Empty Password", "Tom adgangskode"), ("Me", "Mig"), ("identical_file_tip", "Denne fil er identisk med modpartens."), ("show_monitors_tip", "Vis skærme i værktøjsbjælken"), ("View Mode", "Visningstilstand"), - ("login_linux_tip", "Du skal logge på en fjernstyret Linux konto for at aktivere en X skrivebordssession"), - ("verify_rustdesk_password_tip", "Bekræft RustDesk adgangskode"), - ("remember_account_tip", "Husk denne konto"), - ("os_account_desk_tip", "Denne konto benyttes til at logge på fjernsystemet, og aktivere skrivebordssessionen i hovedløs tilstand"), - ("OS Account", "Styresystem konto"), - ("another_user_login_title_tip", "En anden bruger er allerede logget ind"), - ("another_user_login_text_tip", "Frakobl"), - ("xorg_not_found_title_tip", "Xorg ikke fundet"), - ("xorg_not_found_text_tip", "Installér venlist Xorg"), - ("no_desktop_title_tip", "Intet skrivebordsmiljø er tilgængeligt"), - ("no_desktop_text_tip", "Installér venligst GNOME skrivebordet"), + ("verify_rustdesk_password_tip", "Bekræft RustDesk-adgangskode"), ("No need to elevate", "Ingen grund til at elevere"), ("System Sound", "Systemlyd"), ("Default", "Standard"), @@ -483,25 +472,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeraftryk"), ("Copy Fingerprint", "Kopiér fingeraftryk"), ("no fingerprints", "Ingen fingeraftryk"), - ("Select a peer", "Vælg en peer"), - ("Select peers", "Vælg peers"), - ("Plugins", "Plugins"), - ("Uninstall", "Afinstallér"), ("Update", "Opdatér"), - ("Enable", "Aktivér"), - ("Disable", "Deaktivér"), - ("Options", "Valgmuligheder"), ("resolution_original_tip", "Original skærmopløsning"), ("resolution_fit_local_tip", "Tilpas lokal skærmopløsning"), ("resolution_custom_tip", "Bruger-tilpasset skærmopløsning"), ("Collapse toolbar", "Skjul værktøjsbjælke"), ("Accept and Elevate", "Acceptér og elevér"), - ("accept_and_elevate_btn_tooltip", "Acceptér forbindelsen og elevér UAC tilladelser"), + ("accept_and_elevate_btn_tooltip", "Acceptér forbindelsen og elevér UAC-tilladelser"), ("clipboard_wait_response_timeout_tip", "Tiden for at vente på en kopieringsforespørgsel udløb"), ("Incoming connection", "Indgående forbindelse"), ("Outgoing connection", "Udgående forbindelse"), ("Exit", "Afslut"), - ("Open", "Åben"), + ("Open", "Åbn"), ("logout_tip", "Er du sikker på at du vil logge af?"), ("Service", "Tjeneste"), ("Start", "Start"), @@ -510,7 +492,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Sync with recent sessions", "Synkronisér med tidligere sessioner"), ("Sort tags", "Sortér nøgleord"), ("Open connection in new tab", "Åbn forbindelse i en ny fane"), - ("Move tab to new window", "Flyt fane i et nyt vindue"), + ("Move tab to new window", "Flyt fane til et nyt vindue"), ("Can not be empty", "Kan ikke være tom"), ("Already exists", "Findes allerede"), ("Change Password", "Skift adgangskode"), @@ -525,14 +507,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("synced_peer_readded_tip", "Enhederne, som var til stede i de seneste sessioner, vil blive synkroniseret tilbage til adressebogen."), ("Change Color", "Skift farve"), ("Primary Color", "Primær farve"), - ("HSV Color", "HSV farve"), + ("HSV Color", "HSV-farve"), ("Installation Successful!", "Installation fuldført!"), ("Installation failed!", "Installation mislykkedes!"), ("Reverse mouse wheel", "Invertér musehjul"), ("{} sessions", "{} sessioner"), ("scam_title", "ADVARSEL: Du kan blive SVINDLET!"), - ("scam_text1", "Hvis du taler telefon med en person du IKKE kender, og IKKE stoler på, som har bedt dig om at bruge RustDesk til at forbinde til din PC, stop med det samme, og læg på omgående."), - ("scam_text2", "Det er højest sandsynligvis en svinder som forsøger at stjæle dine penge eller andre personlige oplysninger."), + ("scam_text1", "Hvis du taler telefon med en person du IKKE kender, og IKKE stoler på, som har bedt dig om at bruge RustDesk til at forbinde til din PC, så stop med det samme, og læg på omgående."), + ("scam_text2", "Det er højst sandsynligvis en svinder som forsøger at stjæle dine penge eller andre personlige oplysninger."), ("Don't show again", "Vis ikke igen"), ("I Agree", "Jeg accepterer"), ("Decline", "Afvis"), @@ -542,7 +524,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Check for software update on startup", "Søg efter opdateringer ved opstart"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Opgradér venligst RustDesk Server Pro til version {} eller nyere!"), ("pull_group_failed_tip", "Genindlæsning af gruppe mislykkedes"), - ("Filter by intersection", "Filtrér efter intersection"), + ("Filter by intersection", "Filtrér efter fællesmængde"), ("Remove wallpaper during incoming sessions", "Skjul baggrundsskærm ved indgående forbindelser"), ("Test", "Test"), ("display_is_plugged_out_msg", "Skærmen er slukket, skift til den første skærm."), @@ -550,7 +532,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Open in new window", "Åbn i et nyt vindue"), ("Show displays as individual windows", "Vis skærme som selvstændige vinduer"), ("Use all my displays for the remote session", "Brug alle mine skærme til fjernforbindelsen"), - ("selinux_tip", "SELinux er aktiveret på din enhed, som kan forhindre RustDesk i at køre normalt."), + ("selinux_tip", "SELinux er aktiveret på din enhed, hvilket kan forhindre RustDesk i at køre normalt."), ("Change view", "Skift visning"), ("Big tiles", "Store fliser"), ("Small tiles", "Små fliser"), @@ -559,25 +541,25 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Plug out all", "Frakobl alt"), ("True color (4:4:4)", "True color (4:4:4)"), ("Enable blocking user input", "Aktivér blokering af brugerstyring"), - ("id_input_tip", "Du kan indtaste ét ID, en direkte IP adresse, eller et domæne med en port (:).\nHvis du ønsker at forbinde til en enhed på en anden server, tilføj da server adressen (@?key=), fx,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHvis du ønsker adgang til en enhed på en offentlig server, indtast venligst \"@offentlig server\", nøglen er ikke nødvendig for offentlige servere.\n\nHvis du gerne vil tvinge brugen af en relay-forbindelse på den første forbindelse, tilføj \"/r\" efter ID'et, fx, \"9123456234/r\"."), + ("id_input_tip", "Du kan indtaste ét ID, en direkte IP-adresse, eller et domæne med en port (:).\nHvis du ønsker at forbinde til en enhed på en anden server, tilføj da serveradressen (@?key=), fx,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHvis du ønsker adgang til en enhed på en offentlig server, indtast venligst \"@offentlig server\"; nøglen er ikke nødvendig for offentlige servere.\n\nHvis du vil gennemtvinge brug af en relay-forbindelse på den første forbindelse, så tilføj \"/r\" efter ID'et, fx, \"9123456234/r\"."), ("privacy_mode_impl_mag_tip", "Tilstand 1"), ("privacy_mode_impl_virtual_display_tip", "Tilstand 2"), ("Enter privacy mode", "Start privatlivstilstand"), ("Exit privacy mode", "Afslut privatlivstilstand"), - ("idd_not_support_under_win10_2004_tip", "Indirekte grafik drivere er ikke understøttet. Windows 10 version 2004 eller nyere er påkrævet."), + ("idd_not_support_under_win10_2004_tip", "Indirekte grafikdrivere er ikke understøttet. Windows 10 version 2004 eller nyere er påkrævet."), ("input_source_1_tip", "Input kilde 1"), ("input_source_2_tip", "Input kilde 2"), - ("Swap control-command key", "Byt rundt på Control & Command tasterne"), + ("Swap control-command key", "Byt rundt på Ctrl- og Command-tasterne"), ("swap-left-right-mouse", "Byt rundt på venstre og højre musetaster"), - ("2FA code", "To-faktor kode"), + ("2FA code", "To-faktorkode"), ("More", "Mere"), - ("enable-2fa-title", "Tænd for to-faktor godkendelse"), - ("enable-2fa-desc", "Åbn din godkendelsesapp nu. Du kan bruge en godkendelsesapp så som Authy, Microsoft eller Google Authenticator på din telefon eller din PC.\n\nScan QR koden med din app og indtast koden som din app fremviser, for at aktivere for to-faktor godkendelse."), - ("wrong-2fa-code", "Kan ikke verificere koden. Forsikr at koden og tidsindstillingerne på enheden er korrekte"), - ("enter-2fa-title", "To-faktor godkendelse"), - ("Email verification code must be 6 characters.", "E-mail bekræftelseskode skal være mindst 6 tegn"), - ("2FA code must be 6 digits.", "To-faktor kode skal være mindst 6 cifre"), - ("Multiple Windows sessions found", "Flere Windows sessioner fundet"), + ("enable-2fa-title", "Tænd for to-faktorgodkendelse"), + ("enable-2fa-desc", "Åbn din godkendelsesapp nu. Du kan bruge en godkendelsesapp såsom Authy, Microsoft eller Google Authenticator på din telefon eller din PC.\n\nScan QR-koden med din app og indtast koden som din app fremviser for at aktivere to-faktorgodkendelse."), + ("wrong-2fa-code", "Kan ikke verificere koden. Sikr dig at koden og tidsindstillingerne på enheden er korrekte"), + ("enter-2fa-title", "To-faktorgodkendelse"), + ("Email verification code must be 6 characters.", "E-mail-bekræftelseskoden skal være på 6 tegn"), + ("2FA code must be 6 digits.", "To-faktorkoden skal være på 6 cifre"), + ("Multiple Windows sessions found", "Flere Windows-sessioner fundet"), ("Please select the session you want to connect to", "Vælg venligst sessionen du ønsker at forbinde til"), ("powered_by_me", "Drives af RustDesk"), ("outgoing_only_desk_tip", "Dette er en brugertilpasset udgave.\nDu kan forbinde til andre enheder, men andre enheder kan ikke forbinde til din enhed."), @@ -589,44 +571,44 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Set shared password", "Sæt delt adgangskode"), ("Exist in", "Findes i"), ("Read-only", "Skrivebeskyttet"), - ("Read/Write", "Læse/Skrive"), + ("Read/Write", "Læse/skrive"), ("Full Control", "Fuld kontrol"), - ("share_warning_tip", "Felterne for oven er delt og synlige for andre."), + ("share_warning_tip", "Felterne foroven er delt og synlige for andre."), ("Everyone", "Alle"), - ("ab_web_console_tip", "Mere på web konsollen"), - ("allow-only-conn-window-open-tip", "Tillad kun fjernforbindelser hvis RustDesk vinduet er synligt"), - ("no_need_privacy_mode_no_physical_displays_tip", "Ingen fysiske skærme, ingen nødvendighed for at bruge privatlivstilstanden."), + ("ab_web_console_tip", "Mere på webkonsollen"), + ("allow-only-conn-window-open-tip", "Tillad kun fjernforbindelser hvis RustDesk-vinduet er synligt"), + ("no_need_privacy_mode_no_physical_displays_tip", "Ingen fysiske skærme, ikke nødvendigt at bruge privatlivstilstanden."), ("Follow remote cursor", "Følg musemarkør på fjernforbindelse"), - ("Follow remote window focus", "Følg vinduefokus på fjernforbindelse"), + ("Follow remote window focus", "Følg vinduesfokus på fjernforbindelse"), ("default_proxy_tip", "Protokollen og porten som anvendes som standard er Socks5 og 1080"), - ("no_audio_input_device_tip", "Ingen lydinput enhed fundet"), + ("no_audio_input_device_tip", "Ingen lydinputenhed fundet"), ("Incoming", "Indgående"), ("Outgoing", "Udgående"), - ("Clear Wayland screen selection", "Ryd Wayland skærmvalg"), - ("clear_Wayland_screen_selection_tip", "Efter at fravælge den valgte skærm, kan du genvælge skærmen som skal deles."), - ("confirm_clear_Wayland_screen_selection_tip", "Er du sikker på at du vil fjerne Wayland skærmvalget?"), + ("Clear Wayland screen selection", "Ryd Wayland-skærmvalg"), + ("clear_Wayland_screen_selection_tip", "Efter du har fravalgt den valgte skærm, kan du vælge skærmen som skal deles."), + ("confirm_clear_Wayland_screen_selection_tip", "Er du sikker på at du vil fjerne Wayland-skærmvalget?"), ("android_new_voice_call_tip", "Du har modtaget en ny stemmeopkaldsforespørgsel. Hvis du accepterer, vil lyden skifte til stemmekommunikation."), ("texture_render_tip", "Brug tekstur-rendering for at gøre billedkvaliteten blødere. Du kan også prøve at deaktivere denne funktion, hvis du oplever problemer."), ("Use texture rendering", "Anvend tekstur-rendering"), ("Floating window", "Svævende vindue"), - ("floating_window_tip", "Det hjælper på at RustDesk baggrundstjenesten kører"), + ("floating_window_tip", "Det hjælper til at holde RustDesk-baggrundstjenesten kørende"), ("Keep screen on", "Hold skærmen tændt"), ("Never", "Aldrig"), - ("During controlled", "Imens under kontrol"), + ("During controlled", "Under fjernstyring"), ("During service is on", "Imens tjenesten kører"), ("Capture screen using DirectX", "Optag skærm med DirectX"), ("Back", "Tilbage"), ("Apps", "Apps"), ("Volume up", "Skru op for lyd"), ("Volume down", "Skru ned for lyd"), - ("Power", "Tænd/Sluk"), - ("Telegram bot", "Telegram bot"), - ("enable-bot-tip", "Hvis du aktiverer denne funktion, kan du modtage to-faktor godkendelseskoden fra din robot. Den kan også fungere som en notifikation for forbindelsesanmodninger."), + ("Power", "Tænd/sluk"), + ("Telegram bot", "Telegram-bot"), + ("enable-bot-tip", "Hvis du aktiverer denne funktion, kan du modtage to-faktorgodkendelseskoden fra din robot. Den kan også fungere som en notifikation for forbindelsesanmodninger."), ("enable-bot-desc", "1. Åbn en chat med @BotFather.\n2. Send kommandoen \"/newbot\". Du vil modtage en nøgle efter at have gennemført dette trin.\n3. Start en chat med din nyoprettede bot. Send en besked som begynder med skråstreg \"/\", som fx \"/hello\", for at aktivere den.\n"), - ("cancel-2fa-confirm-tip", "Er du sikker på at du vil afbryde to-faktor godkendelse?"), - ("cancel-bot-confirm-tip", "Er du sikker på at du vil afbryde Telegram robotten?"), + ("cancel-2fa-confirm-tip", "Er du sikker på at du vil afbryde to-faktorgodkendelse?"), + ("cancel-bot-confirm-tip", "Er du sikker på at du vil afbryde Telegram-robotten?"), ("About RustDesk", "Om RustDesk"), - ("Send clipboard keystrokes", "Send udklipsholder tastetryk"), + ("Send clipboard keystrokes", "Send udklipsholder-tastetryk"), ("network_error_tip", "Tjek venligst din internetforbindelse, og forsøg igen."), ("Unlock with PIN", "Lås op med PIN"), ("Requires at least {} characters", "Kræver mindst {} tegn"), @@ -636,7 +618,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Manage trusted devices", "Administrér troværdige enheder"), ("Platform", "Platform"), ("Days remaining", "Dage tilbage"), - ("enable-trusted-devices-tip", "Spring to-faktor godkendelse over på troværdige enheder"), + ("enable-trusted-devices-tip", "Spring to-faktorgodkendelse over på troværdige enheder"), ("Parent directory", "mappe"), ("Resume", "Fortsæt"), ("Invalid file name", "Ugyldigt filnavn"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."), ("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."), ("Save as", "Gem som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiér til udklipsholder"), ("Enable remote printer", "Aktivér fjernprinter"), ("Downloading {}", "Downloader {}"), @@ -700,7 +685,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable terminal", "Aktivér terminal"), ("New tab", "Ny fane"), ("Keep terminal sessions on disconnect", "Behold terminalsessioner ved afbrydelse"), - ("Terminal (Run as administrator)", "Terminal (Kør som administrator)"), + ("Terminal (Run as administrator)", "Terminal (kør som administrator)"), ("terminal-admin-login-tip", "Indtast venligst administratorbrugernavnet og adgangskoden på den kontrollerede side."), ("Failed to get user token.", "Kunne ikke hente brugertoken."), ("Incorrect username or password.", "Forkert brugernavn eller adgangskode."), @@ -718,7 +703,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Virtual mouse size", "Størrelse på virtuel mus"), ("Small", "Lille"), ("Large", "Stor"), - ("Show virtual joystick", "Vis virtuel joystick"), + ("Show virtual joystick", "Vis virtuelt joystick"), ("Edit note", "Redigér note"), ("Alias", "Alias"), ("ScrollEdge", "ScrollEdge"), @@ -726,7 +711,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("allow-insecure-tls-fallback-tip", "Som standard verificerer RustDesk servercertifikatet for protokoller, der bruger TLS.\nNår denne indstilling er aktiveret, vil RustDesk springe verificeringstrinnet over og fortsætte, hvis verificeringen mislykkes."), ("Disable UDP", "Deaktivér UDP"), ("disable-udp-tip", "Bestemmer, om der kun skal bruges TCP.\nNår denne indstilling er aktiveret, vil RustDesk ikke længere bruge UDP 21116; i stedet bruges TCP 21116."), - ("server-oss-not-support-tip", "BEMÆRK: RustDesk server OSS indeholder ikke denne funktion."), + ("server-oss-not-support-tip", "BEMÆRK: RustDesk Server OSS indeholder ikke denne funktion."), ("input note here", "indtast note her"), ("note-at-conn-end-tip", "Spørg om note ved afslutningen af forbindelsen"), ("Show terminal extra keys", "Vis ekstra terminaltaster"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Fortsæt"), + ("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."), + ("Lock canvas", "Lås lærred"), + ("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"), + ("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index d71dfa6ce..54a691b7c 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Diese Datei ist identisch mit der Datei der Gegenstelle."), ("show_monitors_tip", "Bildschirme in der Symbolleiste anzeigen"), ("View Mode", "Ansichtsmodus"), - ("login_linux_tip", "Sie müssen sich an einem entfernten Linux-Konto anmelden, um eine X-Desktop-Sitzung zu eröffnen."), ("verify_rustdesk_password_tip", "RustDesk-Passwort bestätigen"), - ("remember_account_tip", "Dieses Konto merken"), - ("os_account_desk_tip", "Dieses Konto wird verwendet, um sich beim entfernten Betriebssystem anzumelden und die Desktop-Sitzung im Headless-Modus zu aktivieren."), - ("OS Account", "Betriebssystem-Konto"), - ("another_user_login_title_tip", "Ein anderer Benutzer ist bereits angemeldet."), - ("another_user_login_text_tip", "Trennen"), - ("xorg_not_found_title_tip", "Xorg nicht gefunden."), - ("xorg_not_found_text_tip", "Bitte installieren Sie Xorg."), - ("no_desktop_title_tip", "Es ist keine Desktopumgebung verfügbar."), - ("no_desktop_text_tip", "Bitte installieren Sie den GNOME-Desktop."), ("No need to elevate", "Erhöhung der Rechte nicht erforderlich"), ("System Sound", "Systemsound"), ("Default", "Systemstandard"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingerabdruck"), ("Copy Fingerprint", "Fingerabdruck kopieren"), ("no fingerprints", "Keine Fingerabdrücke"), - ("Select a peer", "Gegenstelle auswählen"), - ("Select peers", "Gegenstellen auswählen"), - ("Plugins", "Plugins"), - ("Uninstall", "Deinstallieren"), ("Update", "Update"), - ("Enable", "Aktivieren"), - ("Disable", "Deaktivieren"), - ("Options", "Einstellungen"), ("resolution_original_tip", "Originale Auflösung"), ("resolution_fit_local_tip", "Lokale Auflösung anpassen"), ("resolution_custom_tip", "Benutzerdefinierte Auflösung"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."), ("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."), ("Save as", "Speichern unter"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "In Zwischenablage kopieren"), ("Enable remote printer", "Entfernten Drucker aktivieren"), ("Downloading {}", "{} herunterladen"), @@ -774,6 +759,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."), + ("Lock canvas", "Sichtfeld sperren"), + ("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"), + ("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 5ba349a9c..e7c7e1d09 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -294,7 +294,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Κάποιος ενεργοποιεί τη λειτουργία απορρήτου, έξοδος"), ("Unsupported", "Δεν υποστηρίζεται"), ("Peer denied", "Ο απομακρυσμένος σταθμός έχει απορριφθεί"), - ("Please install plugins", "Παρακαλώ εγκαταστήστε τα πρόσθετα"), ("Peer exit", "Ο απομακρυσμένος σταθμός έχει αποσυνδεθεί"), ("Failed to turn off", "Αποτυχία απενεργοποίησης"), ("Turned off", "Απενεργοποιημένο"), @@ -333,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Secure Connection", "Ασφαλής σύνδεση"), ("Insecure Connection", "Μη ασφαλής σύνδεση"), ("Scale original", "Κλιμάκωση πρωτότυπου"), - ("Scale adaptive", "Προσαρμοσμένη κλίμακα"), + ("Scale adaptive", "Αυτόματη προσαρμογή κλίμακας"), ("General", "Γενικά"), ("Security", "Ασφάλεια"), ("Theme", "Θέμα"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Αυτό το αρχείο είναι πανομοιότυπο με αυτό του απομακρυσμένου σταθμού."), ("show_monitors_tip", "Εμφάνιση οθονών στη γραμμή εργαλείων"), ("View Mode", "Λειτουργία προβολής"), - ("login_linux_tip", "Πρέπει να συνδεθείτε σε έναν απομακρυσμένο λογαριασμό Linux για να ενεργοποιήσετε μια συνεδρία επιφάνειας εργασίας X"), ("verify_rustdesk_password_tip", "Επιβεβαιώστε τον κωδικό του RustDesk"), - ("remember_account_tip", "Απομνημόνευση αυτού του λογαριασμού"), - ("os_account_desk_tip", "Αυτός ο λογαριασμός χρησιμοποιείται για σύνδεση στο απομακρυσμένο λειτουργικό σύστημα και ενεργοποίηση της συνεδρίας επιφάνειας εργασίας σε headless"), - ("OS Account", "Λογαριασμός λειτουργικού συστήματος"), - ("another_user_login_title_tip", "Υπάρχει ήδη άλλος συνδεδεμένος χρήστης"), - ("another_user_login_text_tip", "Αποσύνδεση"), - ("xorg_not_found_title_tip", "Δεν βρέθηκε το Xorg"), - ("xorg_not_found_text_tip", "Παρακαλώ εγκαταστήστε το Xorg"), - ("no_desktop_title_tip", "Δεν υπάρχει διαθέσιμο περιβάλλον επιφάνειας εργασίας"), - ("no_desktop_text_tip", "Παρακαλώ εγκαταστήστε το περιβάλλον GNOME"), ("No need to elevate", "Δεν χρειάζεται ανύψωση"), ("System Sound", "Ήχος συστήματος"), ("Default", "Προκαθορισμένο"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Δακτυλικό αποτύπωμα"), ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), - ("Select a peer", "Επιλέξτε έναν σταθμό"), - ("Select peers", "Επιλέξτε σταθμούς"), - ("Plugins", "Επεκτάσεις"), - ("Uninstall", "Κατάργηση εγκατάστασης"), ("Update", "Ενημέρωση"), - ("Enable", "Ενεργοποίηση"), - ("Disable", "Απενεργοποίηση"), - ("Options", "Επιλογές"), ("resolution_original_tip", "Αρχική ανάλυση"), ("resolution_fit_local_tip", "Προσαρμογή στην τοπική ανάλυση"), ("resolution_custom_tip", "Προσαρμοσμένη ανάλυση"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."), ("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."), ("Save as", "Αποθήκευση ως"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Αντιγραφή στο πρόχειρο"), ("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"), ("Downloading {}", "Γίνεται Λήψη {}"), @@ -709,9 +694,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", "Εμφάνιση εικονικού ποντικιού"), @@ -773,7 +758,10 @@ 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.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."), + ("Lock canvas", "Κλείδωμα καμβά"), + ("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"), + ("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index fcd68a300..3ffc0939c 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -155,17 +155,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "This file is identical with the peer's one."), ("show_monitors_tip", "Show monitors in toolbar"), ("View Mode", "View mode"), - ("login_linux_tip", "You need to login to remote Linux account to enable a X desktop session"), ("verify_rustdesk_password_tip", "Verify RustDesk password"), - ("remember_account_tip", "Remember this account"), - ("os_account_desk_tip", "This account is used to login the remote OS and enable the desktop session in headless"), - ("OS Account", "OS account"), - ("another_user_login_title_tip", "Another user already logged in"), - ("another_user_login_text_tip", "Disconnect"), - ("xorg_not_found_title_tip", "Xorg not found"), - ("xorg_not_found_text_tip", "Please install Xorg"), - ("no_desktop_title_tip", "No desktop environment is available"), - ("no_desktop_text_tip", "Please install GNOME desktop"), ("System Sound", "System sound"), ("Copy Fingerprint", "Copy fingerprint"), ("no fingerprints", "No fingerprints"), @@ -285,5 +275,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), + ("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index e6cc0cae5..e1edc9053 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ĉi tiu dosiero estas identa kun tiu de la samulo."), ("show_monitors_tip", "Montri monitorojn en la ilobreto"), ("View Mode", "Rigarda reĝimo"), - ("login_linux_tip", "Vi devas ensaluti al la fora Linuksa konto por ebligi X-labortablan sesion"), ("verify_rustdesk_password_tip", "Kontroli RustDesk-pasvorton"), - ("remember_account_tip", "Memori ĉi tiun konton"), - ("os_account_desk_tip", "Ĉi tiu konto estas uzata por ensaluti al la fora operaciumo kaj ebligi la labortablan sesion en senekrana reĝimo"), - ("OS Account", "Konto de operaciumo"), - ("another_user_login_title_tip", "Alia uzanto jam ensalutis"), - ("another_user_login_text_tip", "Malkonekti"), - ("xorg_not_found_title_tip", "Xorg ne trovita"), - ("xorg_not_found_text_tip", "Bonvolu instali Xorg"), - ("no_desktop_title_tip", "Neniu labortabla medio disponeblas"), - ("no_desktop_text_tip", "Bonvolu instali GNOME-labortablon"), ("No need to elevate", "Ne necesas altigi"), ("System Sound", "Sistema sono"), ("Default", "Implicita"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingrospuro"), ("Copy Fingerprint", "Kopii fingrospuron"), ("no fingerprints", "Neniuj fingrospuroj"), - ("Select a peer", "Elekti samulon"), - ("Select peers", "Elekti samulojn"), - ("Plugins", "Kromprogramoj"), - ("Uninstall", "Malinstali"), ("Update", "Ĝisdatigi"), - ("Enable", "Ebligi"), - ("Disable", "Malebligi"), - ("Options", "Opcioj"), ("resolution_original_tip", "Originala distingivo"), ("resolution_fit_local_tip", "Adapti al loka distingivo"), ("resolution_custom_tip", "Propra distingivo"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."), ("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."), ("Save as", "Konservi kiel"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopii al la poŝo"), ("Enable remote printer", "Ebligi foran presilon"), ("Downloading {}", "Elŝutas {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Daŭrigi"), + ("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."), + ("Lock canvas", "Ŝlosi kanvason"), + ("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"), + ("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 2e7ace9cf..1967d56ab 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este archivo es idéntico al del par."), ("show_monitors_tip", "Mostrar monitores en la barra de herramientas"), ("View Mode", "Modo Vista"), - ("login_linux_tip", "Necesitas iniciar sesión con la cueneta del Linux remoto para activar una sesión de escritorio X"), ("verify_rustdesk_password_tip", "Verificar la contraseña de RustDesk"), - ("remember_account_tip", "Recordar esta cuenta"), - ("os_account_desk_tip", "Esta cueneta se usa para iniciar sesión en el sistema operativo remoto y habilitar la sesión de escritorio en headless."), - ("OS Account", "Cuenta del SO"), - ("another_user_login_title_tip", "Otro usuario ya ha iniciado sesión"), - ("another_user_login_text_tip", "Desconectar"), - ("xorg_not_found_title_tip", "Xorg no hallado"), - ("xorg_not_found_text_tip", "Por favor, instala Xorg"), - ("no_desktop_title_tip", "No hay escritorio disponible"), - ("no_desktop_text_tip", "Por favor, instala GNOME Desktop"), ("No need to elevate", "No es necesario elevar privilegios"), ("System Sound", "Sonido del Sistema"), ("Default", "Predeterminado"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Huella digital"), ("Copy Fingerprint", "Copiar huella digital"), ("no fingerprints", "sin huellas digitales"), - ("Select a peer", "Seleccionar un par"), - ("Select peers", "Seleccionar pares"), - ("Plugins", "Complementos"), - ("Uninstall", "Desinstalar"), ("Update", "Actualizar"), - ("Enable", "Habilitar"), - ("Disable", "Inhabilitar"), - ("Options", "Opciones"), ("resolution_original_tip", "Resolución original"), ("resolution_fit_local_tip", "Ajustar resolución local"), ("resolution_custom_tip", "Resolución personalizada"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."), ("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar al portapapeles"), ("Enable remote printer", "Habilitar impresora remota"), ("Downloading {}", "Descargando {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Continuar"), + ("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."), + ("Lock canvas", "Bloquear lienzo"), + ("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"), + ("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 238c84c88..6b8b715f8 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "See fail on partneri omaga identne."), ("show_monitors_tip", "Kuva kuvarid tööriistaribal"), ("View Mode", "Kuvarežiim"), - ("login_linux_tip", "X-töölaua seansi lubamiseks pead sisse logima Linuxi kaugkontosse."), ("verify_rustdesk_password_tip", "Kinnita RustDeski parool"), - ("remember_account_tip", "Jäta see konto meelde"), - ("os_account_desk_tip", "Seda kontot kasutatakse kaug-opsüsteemi sisselogimiseks ja töölaua seansi lubamiseks headless-režiimis."), - ("OS Account", "Opsüsteemi konto"), - ("another_user_login_title_tip", "Teine kasutaja on juba sisse logitud"), - ("another_user_login_text_tip", "Ühenda lahti"), - ("xorg_not_found_title_tip", "Xorg-i ei leitud"), - ("xorg_not_found_text_tip", "Palun paigalda Xorg"), - ("no_desktop_title_tip", "Töölaud pole saadaval"), - ("no_desktop_text_tip", "Palun paigalda GNOME Desktop"), ("No need to elevate", "Kõrgendamine pole vajalik"), ("System Sound", "Süsteemiheli"), ("Default", "Vaikimisi"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sõrmejälg"), ("Copy Fingerprint", "Kopeeri sõrmejälg"), ("no fingerprints", "Sõrmejäljed puuduvad"), - ("Select a peer", "Vali partner"), - ("Select peers", "Vali partnerid"), - ("Plugins", "Pluginad"), - ("Uninstall", "Desinstalli"), ("Update", "Uuenda"), - ("Enable", "Luba"), - ("Disable", "Keela"), - ("Options", "Valikud"), ("resolution_original_tip", "Originaalne eraldusvõime"), ("resolution_fit_local_tip", "Ühita kohaliku eraldusvõimega"), ("resolution_custom_tip", "Kohandatud eraldusvõime"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."), ("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."), ("Save as", "Salvesta kui"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopeeri lõikelauale"), ("Enable remote printer", "Luba kaugprinter"), ("Downloading {}", "Allalaadimine: {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Jätka"), + ("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."), + ("Lock canvas", "Lukusta lõuend"), + ("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"), + ("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 3fd38eb55..06783379a 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Fitxategi hau parekidearen berdina da."), ("show_monitors_tip", "Erakutsi monitoreak tresna-barran"), ("View Mode", "Ikuspen modua"), - ("login_linux_tip", "Urruneko Linux kontu batera hasi behar duzu saioa X mahaigain saio bat gaitzeko"), ("verify_rustdesk_password_tip", "Berretsi RustDesk pasahitza"), - ("remember_account_tip", "Gogoratu kontu hau"), - ("os_account_desk_tip", "Kontu hau bururik gabe urruneko SE hasi eta mahaigaineko saioa gaitzeko erabiltzen da"), - ("OS Account", "SE kontua"), - ("another_user_login_title_tip", "Beste erabiltzaile batek saioa hasi du dagoeneko"), - ("another_user_login_text_tip", "Deskonektatu"), - ("xorg_not_found_title_tip", "Ez da Xorg aurkitu"), - ("xorg_not_found_text_tip", "Mesedez, instalatu ezazu Xorg"), - ("no_desktop_title_tip", "Ez dago mahaigainik eskuragarri"), - ("no_desktop_text_tip", "Mesedez, instalatu ezazu GNOME Desktop"), ("No need to elevate", "Ez da beharrezkoa pribilegioen maila igotzea"), ("System Sound", "Sistemaren soinua"), ("Default", "Lehenetsia"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Hatz-marka"), ("Copy Fingerprint", "Kopiatu hatz-marka"), ("no fingerprints", "hatz-markarik ez"), - ("Select a peer", "Hautatu parekidea"), - ("Select peers", "Hautatu parekideak"), - ("Plugins", "Pluginak"), - ("Uninstall", "Desinstalatu"), ("Update", "Eguneratu"), - ("Enable", "Gaitu"), - ("Disable", "Desgaitu"), - ("Options", "Aukerak"), ("resolution_original_tip", "Jatorrizko bereizmena"), ("resolution_fit_local_tip", "Bereizmen lokala egokitu"), ("resolution_custom_tip", "Bereizmen pertsonalizatua"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."), ("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."), ("Save as", "Gorde honela"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiatu arbelera"), ("Enable remote printer", "Gaitu urruneko inprimagailua"), ("Downloading {}", "{} deskargatzen"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Jarraitu"), + ("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."), + ("Lock canvas", "Blokeatu oihala"), + ("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"), + ("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 1e4039be7..dd16ed09a 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", "خاموش شد"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "این فایل با فایل همتا یکسان است."), ("show_monitors_tip", "نمایش مانیتورها در نوار ابزار"), ("View Mode", "حالت مشاهده"), - ("login_linux_tip", "برای فعال کردن دسکتاپ X، باید به حساب لینوکس راه دور وارد شوید"), ("verify_rustdesk_password_tip", "رمز عبور RustDesk را تأیید کنید"), - ("remember_account_tip", "این حساب را به خاطر بسپارید"), - ("os_account_desk_tip", "این حساب برای ورود به سیستم عامل راه دور و فعال کردن جلسه دسکتاپ در هدلس استفاده می شود"), - ("OS Account", "حساب کاربری سیستم عامل"), - ("another_user_login_title_tip", "کاربر دیگری قبلاً وارد شده است"), - ("another_user_login_text_tip", "قطع شدن"), - ("xorg_not_found_title_tip", "پیدا نشد Xorg"), - ("xorg_not_found_text_tip", "لطفا Xorg را نصب کنید"), - ("no_desktop_title_tip", "هیچ دسکتاپی در دسترس نیست"), - ("no_desktop_text_tip", "لطفا دسکتاپ گنوم را نصب کنید"), ("No need to elevate", "نیازی به ارتقاء نیست"), ("System Sound", "صدای سیستم"), ("Default", "پیش فرض"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "\n اثر انگشت"), ("Copy Fingerprint", "کپی کردن اثر انگشت"), ("no fingerprints", "بدون اثر انگشت"), - ("Select a peer", "یک همتا را انتخاب کنید"), - ("Select peers", "همتایان را انتخاب کنید"), - ("Plugins", "پلاگین ها"), - ("Uninstall", "حذف نصب"), ("Update", "به روز رسانی"), - ("Enable", "فعال کردن"), - ("Disable", "غیر فعال کردن"), - ("Options", "گزینه ها"), ("resolution_original_tip", "وضوح اصلی"), ("resolution_fit_local_tip", "متناسب با وضوح محلی"), ("resolution_custom_tip", "وضوح سفارشی"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."), ("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."), ("Save as", "ذخیره به عنوان"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "در کلیپ بورد کپی کنید"), ("Enable remote printer", "چاپگر از راه دور را فعال کنید"), ("Downloading {}", "بارگیری {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "ادامه"), + ("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."), + ("Lock canvas", "قفل کردن صفحه"), + ("Sync clipboard between sessions", "همگام‌سازی کلیپ‌بورد بین نشست‌ها"), + ("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی می‌شوند به کلیپ‌بورد سایر نشست‌های متصل شما نیز ارسال می‌شوند."), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 2a21ba049..3b00e00b1 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Saman niminen tiedosto on jo olemassa"), ("show_monitors_tip", "Näytä kaikki käytettävissä olevat näytöt"), ("View Mode", "Näkymätila"), - ("login_linux_tip", "Kirjaudu sisään Linux käyttäjätunnuksellasi"), ("verify_rustdesk_password_tip", "Vahvista RustDesk salasanasi kirjautumista varten"), - ("remember_account_tip", "Muista tilini kirjautumista varten"), - ("os_account_desk_tip", "Käytä käyttöjärjestelmän käyttäjätiliä kirjautumiseen"), - ("OS Account", "Käyttöjärjestelmän tili"), - ("another_user_login_title_tip", "Toinen käyttäjä on kirjautunut sisään"), - ("another_user_login_text_tip", "Etäistunto keskeytetään, koska toinen käyttäjä on ottanut hallinnan."), - ("xorg_not_found_title_tip", "Xorg ei löydy"), - ("xorg_not_found_text_tip", "X11 palvelinta ei löydetty. Vaihda Xorg ympäristöön jatkaaksesi."), - ("no_desktop_title_tip", "Työpöytää ei havaittu"), - ("no_desktop_text_tip", "Työpöytäympäristöä ei löydy. Asenna esimerkiksi GNOME tai XFCE."), ("No need to elevate", "Oikeuksien korotusta ei tarvita"), ("System Sound", "Järjestelmän ääni"), ("Default", "Oletus"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sormenjälki"), ("Copy Fingerprint", "Kopioi sormenjälki"), ("no fingerprints", "Ei sormenjälkiä"), - ("Select a peer", "Valitse vastapää"), - ("Select peers", "Valitse useita vastapään laitteita"), - ("Plugins", "Laajennukset"), - ("Uninstall", "Poista asennus"), ("Update", "Päivitä"), - ("Enable", "Ota käyttöön"), - ("Disable", "Poista käytöstä"), - ("Options", "Asetukset"), ("resolution_original_tip", "Näytä alkuperäisessä resoluutiossa ilman skaalausta"), ("resolution_fit_local_tip", "Sovita etänäyttö paikalliseen näkymään"), ("resolution_custom_tip", "Käytä mukautettua resoluutiota"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"), ("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"), ("Save as", "Tallenna nimellä"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopioi leikepöydälle"), ("Enable remote printer", "Ota etätulostin käyttöön"), ("Downloading {}", "Ladataan {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Jatka"), + ("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."), + ("Lock canvas", "Lukitse näkymä"), + ("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"), + ("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 8359587a2..11372cc51 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é"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ce fichier est identique à celui sur l’appareil distant."), ("show_monitors_tip", "Afficher les écrans dans la barre d’outils"), ("View Mode", "Mode vue"), - ("login_linux_tip", "Vous devez vous connecter au compte Linux distant pour établir une session de bureau X"), ("verify_rustdesk_password_tip", "Vérifier le mot de passe RustDesk"), - ("remember_account_tip", "Se souvenir de ce compte"), - ("os_account_desk_tip", "Ce compte est utilisé pour se connecter au système d’exploitation distant et activer la session de bureau en mode sans affichage"), - ("OS Account", "Compte du système d’exploitation"), - ("another_user_login_title_tip", "Un autre utilisateur est déjà connecté"), - ("another_user_login_text_tip", "Déconnecter"), - ("xorg_not_found_title_tip", "Xorg introuvable"), - ("xorg_not_found_text_tip", "Veuillez installer Xorg"), - ("no_desktop_title_tip", "Aucun environnement de bureau n’est disponible"), - ("no_desktop_text_tip", "Veuillez installer l’environnement de bureau GNOME"), ("No need to elevate", "Élever les privilèges n’est pas nécessaire"), ("System Sound", "Son système"), ("Default", "Défaut"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Empreinte numérique"), ("Copy Fingerprint", "Copier l’empreinte numérique"), ("no fingerprints", "Aucune empreinte numérique"), - ("Select a peer", "Sélectionnez l’appareil distant"), - ("Select peers", "Sélectionnez les appareils distants"), - ("Plugins", "Plugins"), - ("Uninstall", "Désinstaller"), ("Update", "Mettre à jour"), - ("Enable", "Activer"), - ("Disable", "Désactiver"), - ("Options", "Options"), ("resolution_original_tip", "Résolution d’origine"), ("resolution_fit_local_tip", "Adapter à la résolution locale"), ("resolution_custom_tip", "Résolution personnalisée"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."), ("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."), ("Save as", "Enregistrer sous"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copier dans le presse-papier"), ("Enable remote printer", "Activer l’impression à distance"), ("Downloading {}", "Téléchargement de {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Continuer"), + ("Browser didn't open? Use the url below to sign in.", "Le navigateur ne s’est pas ouvert ? Utilisez l’URL ci-dessous pour vous connecter."), + ("Lock canvas", "Verrouiller la vue"), + ("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"), + ("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 97c3e9171..edacbb4f5 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", "გამორთული"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ფაილი იდენტურია დისტანციურ კვანძზე არსებული ფაილის"), ("show_monitors_tip", "მონიტორების ჩვენება ხელსაწყოთა პანელზე"), ("View Mode", "ნახვის რეჟიმი"), - ("login_linux_tip", "X სამუშაო მაგიდის სესიის ჩასართავად, საჭიროა დისტანციურ Linux ანგარიშში შესვლა."), ("verify_rustdesk_password_tip", "დაადასტურეთ RustDesk-ის პაროლი"), - ("remember_account_tip", "დაიმახსოვრეთ ეს ანგარიში"), - ("os_account_desk_tip", "ეს ანგარიში გამოიყენება დისტანციურ ოპერაციულ სისტემაში შესასვლელად და headless რეჟიმში სამუშაო მაგიდის სესიის ჩასართავად."), - ("OS Account", "ოპერაციული სისტემის ანგარიში"), - ("another_user_login_title_tip", "სხვა მომხმარებელი უკვე შესულია სისტემაში"), - ("another_user_login_text_tip", "გათიშვა"), - ("xorg_not_found_title_tip", "Xorg ვერ მოიძებნა"), - ("xorg_not_found_text_tip", "დააინსტალირეთ Xorg"), - ("no_desktop_title_tip", "სამუშაო მაგიდა არ არის ხელმისაწვდომი"), - ("no_desktop_text_tip", "დააინსტალირეთ GNOME Desktop"), ("No need to elevate", "უფლებების აწევა არ არის საჭირო"), ("System Sound", "სისტემური ხმა"), ("Default", "ნაგულისხმევი"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ანაბეჭდი"), ("Copy Fingerprint", "ანაბეჭდის კოპირება"), ("no fingerprints", "ანაბეჭდები არ არის"), - ("Select a peer", "აირჩიეთ დისტანციური კვანძი"), - ("Select peers", "აირჩიეთ დისტანციური კვანძები"), - ("Plugins", "დანამატები"), - ("Uninstall", "წაშლა"), ("Update", "განახლება"), - ("Enable", "ჩართვა"), - ("Disable", "გამორთვა"), - ("Options", "პარამეტრები"), ("resolution_original_tip", "საწყისი გარჩევადობა"), ("resolution_fit_local_tip", "ლოკალური გარჩევადობის შესაბამისი"), ("resolution_custom_tip", "მორგებული გარჩევადობა"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."), ("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."), ("Save as", "შენახვა როგორც"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ბუფერში კოპირება"), ("Enable remote printer", "დისტანციური პრინტერის ჩართვა"), ("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"), @@ -773,7 +758,10 @@ 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.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."), + ("Lock canvas", "ტილოს დაბლოკვა"), + ("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"), + ("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index c9c2c9177..c0d722940 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", "બંધ કરવામાં આવ્યું"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "આ ફાઇલ પહેલેથી જ અસ્તિત્વમાં છે."), ("show_monitors_tip", "ટૂલબારમાં મોનિટર બતાવો"), ("View Mode", "વ્યુ મોડ"), - ("login_linux_tip", "રિમોટ Linux સત્ર માટે તમારે લોગિન કરવું પડશે"), ("verify_rustdesk_password_tip", "RustDesk પાસવર્ડ ચકાસો"), - ("remember_account_tip", "આ ખાતું યાદ રાખો"), - ("os_account_desk_tip", "એક્સેસ માટે OS ખાતાનો ઉપયોગ કરો"), - ("OS Account", "OS ખાતું"), - ("another_user_login_title_tip", "બીજો યુઝર પહેલેથી લોગિન છે"), - ("another_user_login_text_tip", "ડિસ્કનેક્ટ કરો અને ફરી પ્રયાસ કરો"), - ("xorg_not_found_title_tip", "Xorg મળ્યું નથી"), - ("xorg_not_found_text_tip", "કૃપા કરીને Xorg ઇન્સ્ટોલ કરો"), - ("no_desktop_title_tip", "કોઈ ડેસ્કટોપ ઉપલબ્ધ નથી"), - ("no_desktop_text_tip", "કૃપા કરીને Linux ડેસ્કટોપ ઇન્સ્ટોલ કરો"), ("No need to elevate", "એલિવેટ કરવાની જરૂર નથી"), ("System Sound", "સિસ્ટમ સાઉન્ડ"), ("Default", "ડિફોલ્ટ"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ફિંગરપ્રિન્ટ"), ("Copy Fingerprint", "ફિંગરપ્રિન્ટ કોપી કરો"), ("no fingerprints", "કોઈ ફિંગરપ્રિન્ટ નથી"), - ("Select a peer", "એક પીઅર પસંદ કરો"), - ("Select peers", "પીઅર્સ પસંદ કરો"), - ("Plugins", "પ્લગઇન્સ"), - ("Uninstall", "અનઇન્સ્ટોલ કરો"), ("Update", "અપડેટ કરો"), - ("Enable", "સક્ષમ કરો"), - ("Disable", "અક્ષમ કરો"), - ("Options", "વિકલ્પો"), ("resolution_original_tip", "મૂળ રિઝોલ્યુશન"), ("resolution_fit_local_tip", "સ્ક્રીન મુજબ ફીટ કરો"), ("resolution_custom_tip", "કસ્ટમ રિઝોલ્યુશન"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."), ("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"), ("Save as", "તરીકે સાચવો"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"), ("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"), ("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"), @@ -773,7 +758,10 @@ 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.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."), + ("Lock canvas", "કેનવાસ લોક કરો"), + ("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"), + ("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 3ea0d7626..8dd783f30 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", "מכובה"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "קובץ זה זהה לקובץ שבצד העמית."), ("show_monitors_tip", "הצג מסכים בסרגל כלים"), ("View Mode", "מצב תצוגה"), - ("login_linux_tip", "עליך להתחבר לחשבון Linux מרוחק כדי לאפשר פעילות שולחן עבודה X"), ("verify_rustdesk_password_tip", "אמת סיסמת RustDesk"), - ("remember_account_tip", "זכור חשבון זה"), - ("os_account_desk_tip", "חשבון זה משמש להתחברות למערכת ההפעלה המרוחקת ולהפעלת שולחן עבודה במצב לא מקוון"), - ("OS Account", "חשבון מערכת הפעלה"), - ("another_user_login_title_tip", "משתמש אחר כבר התחבר"), - ("another_user_login_text_tip", "נתק"), - ("xorg_not_found_title_tip", "Xorg לא נמצא"), - ("xorg_not_found_text_tip", "אנא התקן Xorg"), - ("no_desktop_title_tip", "אין שולחן עבודה זמין"), - ("no_desktop_text_tip", "אנא התקן שולחן עבודה GNOME"), ("No need to elevate", "אין צורך בהעלאת הרשאות"), ("System Sound", "צליל מערכת"), ("Default", "ברירת מחדל"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Select a peer", "בחר עמית"), - ("Select peers", "בחר עמיתים"), - ("Plugins", "תוספים"), - ("Uninstall", "הסר"), ("Update", "עדכן"), - ("Enable", "פועל"), - ("Disable", "כבוי"), - ("Options", "אפשרויות"), ("resolution_original_tip", "רזולוציה מקורית"), ("resolution_fit_local_tip", "התאם לרזולוציה מקומית"), ("resolution_custom_tip", "רזולוציה מותאמת אישית"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"), ("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"), ("Save as", "שמור בשם"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "העתק ללוח"), ("Enable remote printer", "אפשר מדפסת מרוחקת"), ("Downloading {}", "מוריד את {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "המשך"), + ("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."), + ("Lock canvas", "נעל לוח ציור"), + ("Sync clipboard between sessions", "סנכרן לוח בין סשנים"), + ("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index e3851a0d8..f9eebc603 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", "बंद कर दिया गया"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "यह फ़ाइल पहले से ही मौजूद है।"), ("show_monitors_tip", "टूलबार में मॉनिटर दिखाएं"), ("View Mode", "व्यू मोड"), - ("login_linux_tip", "रिमोट Linux सत्र शुरू करने के लिए आपको लॉगिन करना होगा"), ("verify_rustdesk_password_tip", "RustDesk पासवर्ड सत्यापित करें"), - ("remember_account_tip", "इस खाते को याद रखें"), - ("os_account_desk_tip", "रिमोट डेस्कटॉप को एक्सेस करने के लिए OS खाते का उपयोग करें"), - ("OS Account", "OS खाता"), - ("another_user_login_title_tip", "एक अन्य उपयोगकर्ता पहले से ही लॉगिन है"), - ("another_user_login_text_tip", "डिस्कनेक्ट करें और पुनः प्रयास करें"), - ("xorg_not_found_title_tip", "Xorg नहीं मिला"), - ("xorg_not_found_text_tip", "कृपया Xorg इंस्टॉल करें"), - ("no_desktop_title_tip", "कोई डेस्कटॉप उपलब्ध नहीं है"), - ("no_desktop_text_tip", "कृपया Linux डेस्कटॉप इंस्टॉल करें"), ("No need to elevate", "एलीवेट करने की आवश्यकता नहीं है"), ("System Sound", "सिस्टम साउंड"), ("Default", "डिफ़ॉल्ट"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "फिंगरप्रिंट"), ("Copy Fingerprint", "फिंगरप्रिंट कॉपी करें"), ("no fingerprints", "कोई फिंगरप्रिंट नहीं"), - ("Select a peer", "एक पीयर (Peer) चुनें"), - ("Select peers", "पीयर्स चुनें"), - ("Plugins", "प्लगइन्स"), - ("Uninstall", "अनइंस्टॉल करें"), ("Update", "अपडेट करें"), - ("Enable", "सक्षम करें"), - ("Disable", "अक्षम करें"), - ("Options", "विकल्प"), ("resolution_original_tip", "मूल रिज़ॉल्यूशन"), ("resolution_fit_local_tip", "स्थानीय स्क्रीन में फिट करें"), ("resolution_custom_tip", "कस्टम रिज़ॉल्यूशन"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"), ("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"), ("Save as", "इस रूप में सहेजें"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"), ("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"), ("Downloading {}", "{} डाउनलोड हो रहा है"), @@ -773,7 +758,10 @@ 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.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"), + ("Lock canvas", "कैनवास लॉक करें"), + ("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"), + ("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index ee894b0e7..a793e519e 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ova je datoteka identična partnerskoj datoteci."), ("show_monitors_tip", "Prikažite monitore na alatnoj traci"), ("View Mode", "Način prikaza"), - ("login_linux_tip", "Da biste omogućili sesiju X radne površine, morate se prijaviti na udaljeni Linux račun."), ("verify_rustdesk_password_tip", "Provjera lozinke za RustDesk"), - ("remember_account_tip", "Zapamti ovaj račun"), - ("os_account_desk_tip", "Ovaj se račun koristi za prijavu na udaljeni operativni sustav i za omogućavanje sesije radne površine u bezglavom načinu rada."), - ("OS Account", "Račun operativnog sustava"), - ("another_user_login_title_tip", "Drugi korisnik je već prijavljen"), - ("another_user_login_text_tip", "Prekini vezu"), - ("xorg_not_found_title_tip", "Xorg nije pronađen"), - ("xorg_not_found_text_tip", "Molimo instalirajte Xorg"), - ("no_desktop_title_tip", "Nema dostupne radne površine"), - ("no_desktop_text_tip", "Molimo instalirajte GNOME"), ("No need to elevate", "Nije potrebno povećanje"), ("System Sound", "Zvuk sustava"), ("Default", "Zadano"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopirat otisak"), ("no fingerprints", "nema otiska"), - ("Select a peer", "Izbor druge strane"), - ("Select peers", "Odaberite druge strane"), - ("Plugins", "Dodaci"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Dopustiti"), - ("Disable", "Zabraniti"), - ("Options", "Mogućnosti"), ("resolution_original_tip", "Izvorna rezolucija"), ("resolution_fit_local_tip", "Podesite lokalnu rezoluciju"), ("resolution_custom_tip", "Prilagođena rezolucija"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."), ("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."), ("Save as", "Spremi kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u međuspremnik"), ("Enable remote printer", "Omogući udaljeni pisač"), ("Downloading {}", "Preuzimanje {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Nastavi"), + ("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."), + ("Lock canvas", "Zaključaj pozadinu"), + ("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"), + ("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 14a85f1f7..4a5a737da 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ez a fájl megegyezik a távoli állomás fájljával."), ("show_monitors_tip", "Képernyők megjelenítése az eszköztáron"), ("View Mode", "Nézet mód"), - ("login_linux_tip", "Az X-asztal munkamenet megnyitásához be kell jelentkeznie egy távoli Linux-fiókba."), ("verify_rustdesk_password_tip", "RustDesk jelszó megerősítése"), - ("remember_account_tip", "Emlékezzen erre a fiókra"), - ("os_account_desk_tip", "Ezzel a fiókkal bejelentkezhet a távoli operációs rendszerbe, és aktiválhatja az asztali munkamenetet fej nélküli módban."), - ("OS Account", "OS fiók"), - ("another_user_login_title_tip", "Egy másik felhasználó már bejelentkezett."), - ("another_user_login_text_tip", "Különálló"), - ("xorg_not_found_title_tip", "Xorg nem található."), - ("xorg_not_found_text_tip", "Telepítse az Xorgot."), - ("no_desktop_title_tip", "Nem áll rendelkezésre asztali környezet."), - ("no_desktop_text_tip", "Telepítse a GNOME asztali környezetet."), ("No need to elevate", "Nem szükséges megemelni"), ("System Sound", "Rendszer hangok"), ("Default", "Alapértelmezett"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Ujjlenyomat"), ("Copy Fingerprint", "Ujjlenyomat másolása"), ("no fingerprints", "nincsenek ujjlenyomatok"), - ("Select a peer", "Egy távoli állomás kiválasztása"), - ("Select peers", "Távoli állomások kiválasztása"), - ("Plugins", "Beépülő modulok"), - ("Uninstall", "Eltávolítás"), ("Update", "Frissítés"), - ("Enable", "Engedélyezés"), - ("Disable", "Letiltás"), - ("Options", "Opciók"), ("resolution_original_tip", "Eredeti felbontás"), ("resolution_fit_local_tip", "Helyi felbontás beállítása"), ("resolution_custom_tip", "Testre szabható felbontás"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"), ("screenshot-action-tip", "Képernyőkép-művelet"), ("Save as", "Mentés másként"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Másolás a vágólapra"), ("Enable remote printer", "Távoli nyomtatók engedélyezése"), ("Downloading {}", "{} letöltése"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Folytatás"), + ("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."), + ("Lock canvas", "Nézet zárolása"), + ("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"), + ("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 7ba387e48..f5a8918bd 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Data ini identik dengan milik rekan"), ("show_monitors_tip", "Tampilkan monitor di toolbar"), ("View Mode", "Mode Tampilan"), - ("login_linux_tip", "Anda harus masuk ke akun remote linux untuk mengaktifkan sesi X desktop"), ("verify_rustdesk_password_tip", "Verifikasi Kata Sandi RustDesk"), - ("remember_account_tip", "Ingat akun ini"), - ("os_account_desk_tip", "Akun ini digunakan untuk masuk ke sistem operasi remote dan mengaktifkan sesi desktop dalam mode tanpa tampilan (headless)"), - ("OS Account", "Akun OS"), - ("another_user_login_title_tip", "Akun ini sedang digunakan"), - ("another_user_login_text_tip", "Putuskan koneksi diperangkat lain"), - ("xorg_not_found_title_tip", "Xorg tidak ditemukan"), - ("xorg_not_found_text_tip", "Silahkan install Xorg"), - ("no_desktop_title_tip", "Desktop tidak tersedia"), - ("no_desktop_text_tip", "Silahkan install GNOME Desktop"), ("No need to elevate", "Tidak perlu elevasi"), ("System Sound", "Suara Sistem"), ("Default", "Default"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sidik jari"), ("Copy Fingerprint", "Salin sidik jari"), ("no fingerprints", "Tidak ada sidik jari"), - ("Select a peer", "Pilih rekan"), - ("Select peers", "Pilih rekan-rekan"), - ("Plugins", "Plugin"), - ("Uninstall", "Hapus instalasi"), ("Update", "Perbarui"), - ("Enable", "Aktifkan"), - ("Disable", "Nonaktifkan"), - ("Options", "Opsi"), ("resolution_original_tip", "Resolusi original"), ("resolution_fit_local_tip", "Sesuaikan resolusi lokal"), ("resolution_custom_tip", "Resolusi kustom"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."), ("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."), ("Save as", "Simpan sebagai"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Salin ke papan klip"), ("Enable remote printer", "Aktifkan printer jarak jauh"), ("Downloading {}", "Mendownload {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Lanjutkan"), + ("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."), + ("Lock canvas", "Kunci kanvas"), + ("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"), + ("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 1297972df..73e5e6d83 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Questo file è identico a quello nel dispositivo remoto."), ("show_monitors_tip", "Visualizza schermi nella barra strumenti"), ("View Mode", "Modalità visualizzazione"), - ("login_linux_tip", "Accedi all'account Linux remoto"), ("verify_rustdesk_password_tip", "Conferma password RustDesk"), - ("remember_account_tip", "Ricorda questo account"), - ("os_account_desk_tip", "Questo account viene usato per accedere al sistema operativo remoto e attivare la sessione desktop in modalità non presidiata."), - ("OS Account", "Account sistema operativo"), - ("another_user_login_title_tip", "È già loggato un altro utente."), - ("another_user_login_text_tip", "Separato"), - ("xorg_not_found_title_tip", "Xorg non trovato."), - ("xorg_not_found_text_tip", "Installa Xorg."), - ("no_desktop_title_tip", "Non è presente alcun ambiente desktop disponibile."), - ("no_desktop_text_tip", "Installa il desktop GNOME."), ("No need to elevate", "Elevazione dei privilegi non richiesta"), ("System Sound", "Dispositivo audio sistema"), ("Default", "Predefinita"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Copia firma digitale"), ("no fingerprints", "Nessuna firma digitale"), - ("Select a peer", "Seleziona dispositivo remoto"), - ("Select peers", "Seleziona dispositivi remoti"), - ("Plugins", "Plugin"), - ("Uninstall", "Disinstalla"), ("Update", "Aggiorna"), - ("Enable", "Abilita"), - ("Disable", "Disabilita"), - ("Options", "Opzioni"), ("resolution_original_tip", "Risoluzione originale"), ("resolution_fit_local_tip", "Adatta risoluzione locale"), ("resolution_custom_tip", "Risoluzione personalizzata"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."), ("screenshot-action-tip", "Seleziona come continuare con la schermata."), ("Save as", "Salva come"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia negli appunti"), ("Enable remote printer", "Abilita stampante remota"), ("Downloading {}", "Download {}"), @@ -774,6 +759,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."), + ("Lock canvas", "Blocca tela"), + ("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"), + ("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index ba6e6cb09..dad7b0557 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", "オフになりました"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "このファイルはリモートコンピューターと同一です。"), ("show_monitors_tip", "ツールバーにディスプレイを表示する"), ("View Mode", "表示モード"), - ("login_linux_tip", "X デスクトップのセッションにログインするには、リモートコンピューターのLinuxアカウントにログインする必要があります。"), ("verify_rustdesk_password_tip", "RustDesk のパスワードを確認する"), - ("remember_account_tip", "このアカウントを記憶する"), - ("os_account_desk_tip", "このアカウントは、リモートコンピューターの OS にログインし、ヘッドレスでセッションを有効化するために使用されます。"), - ("OS Account", "OS のアカウント"), - ("another_user_login_title_tip", "他のユーザーがすでにログインしています"), - ("another_user_login_text_tip", "切断しました"), - ("xorg_not_found_title_tip", "Xorg サーバーが見つかりませんでした。"), - ("xorg_not_found_text_tip", "Xorg をインストールしてください"), - ("no_desktop_title_tip", "デスクトップ環境が見つかりませんでした。"), - ("no_desktop_text_tip", "GNOME デスクトップ環境をインストールしてください"), ("No need to elevate", "権限昇格の必要はありません"), ("System Sound", "システム音声"), ("Default", "既定"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), - ("Select a peer", "リモートコンピューターを選択"), - ("Select peers", "複数のリモートコンピューターを選択"), - ("Plugins", "プラグイン"), - ("Uninstall", "アンインストール"), ("Update", "更新"), - ("Enable", "有効"), - ("Disable", "無効"), - ("Options", "設定"), ("resolution_original_tip", "オリジナルの解像度"), ("resolution_fit_local_tip", "ローカル解像度に合わせる"), ("resolution_custom_tip", "カスタム解像度"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"), ("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"), ("Save as", "保存先"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "クリップボードにコピー"), ("Enable remote printer", "リモートプリンターを有効化する"), ("Downloading {}", "{} をダウンロード中"), @@ -773,7 +758,10 @@ 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.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"), + ("Lock canvas", "キャンバスをロック"), + ("Sync clipboard between sessions", "セッション間でクリップボードを同期"), + ("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f60af542b..2151c9b8b 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", "꺼짐"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "이 파일은 상대방의 파일과 일치합니다."), ("show_monitors_tip", "도구 모음에 모니터 표시"), ("View Mode", "보기 모드"), - ("login_linux_tip", "X 데스크탑을 활성화하려면 제어되는 터미널의 Linux 계정에 로그인하세요"), ("verify_rustdesk_password_tip", "RustDesk 비밀번호 확인"), - ("remember_account_tip", "이 계정 기억하기"), - ("os_account_desk_tip", "이 계정은 원격 OS에 로그인하고 헤드리스에서 데스크탑 세션을 활성화하는 데 사용됩니다."), - ("OS Account", "OS 계정"), - ("another_user_login_title_tip", "다른 사용자가 이미 로그인했습니다"), - ("another_user_login_text_tip", "연결 끊기"), - ("xorg_not_found_title_tip", "Xorg를 찾을 수 없습니다"), - ("xorg_not_found_text_tip", "Xorg를 설치해 주세요"), - ("no_desktop_title_tip", "사용 가능한 데스크탑 환경이 없습니다"), - ("no_desktop_text_tip", "GNOME 데스크탑을 설치해 주세요"), ("No need to elevate", "권한 상승이 필요없습니다"), ("System Sound", "시스템 소리"), ("Default", "기본"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Select a peer", "피어 선택"), - ("Select peers", "피어 선택"), - ("Plugins", "플러그인"), - ("Uninstall", "설치 제거"), ("Update", "업데이트"), - ("Enable", "허용"), - ("Disable", "사용 안 함"), - ("Options", "옵션"), ("resolution_original_tip", "원본 해상도"), ("resolution_fit_local_tip", "로컬 화면에 맞춤"), ("resolution_custom_tip", "사용자 지정 해상도"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."), ("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."), ("Save as", "다른 이름으로 저장"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "클립보드에 복사"), ("Enable remote printer", "원격 프린터 허용"), ("Downloading {}", "{} 다운로드 중"), @@ -773,7 +758,10 @@ 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.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."), + ("Lock canvas", "캔버스 잠금"), + ("Sync clipboard between sessions", "세션 간 클립보드 동기화"), + ("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index fc59efde3..27ed4e8d6 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", "Өшірілген"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Бұл файыл пирдікімен бірдей."), ("show_monitors_tip", "Мониторларды құралдар тақтасында көрсету"), ("View Mode", "Көру модасы"), - ("login_linux_tip", "X жұмыс үстелі сешін іске қосу үшін қашықтағы Linux есепкісіне кіруіңіз керек"), ("verify_rustdesk_password_tip", "RustDesk құпия сөзін тексеру"), - ("remember_account_tip", "Бұл есепкіні есте сақтау"), - ("os_account_desk_tip", "Бұл есепкі қашықтағы OS-қа кіру және headless режимде жұмыс үстелі сешін іске қосу үшін қолданылады"), - ("OS Account", "OS есепкісі"), - ("another_user_login_title_tip", "Басқа қолданушы әлдеқашан кіріп қойған"), - ("another_user_login_text_tip", "Ажырату"), - ("xorg_not_found_title_tip", "Xorg табылмады"), - ("xorg_not_found_text_tip", "Xorg орнатуды өтінеміз"), - ("no_desktop_title_tip", "Жұмыс үстелі ортасы қолжетімсіз"), - ("no_desktop_text_tip", "GNOME жұмыс үстелін орнатуды өтінеміз"), ("No need to elevate", "Артықшылықты көтерудің қажеті жоқ"), ("System Sound", "Жүйе дыбысы"), ("Default", "Әдепкі"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Саусақ ізі"), ("Copy Fingerprint", "Саусақ ізін көшіру"), ("no fingerprints", "Саусақ іздері жоқ"), - ("Select a peer", "Пир таңдау"), - ("Select peers", "Пирлерді таңдау"), - ("Plugins", "Плагиндер"), - ("Uninstall", "Жою"), ("Update", "Жаңарту"), - ("Enable", "Қосу"), - ("Disable", "Өшіру"), - ("Options", "Опциялар"), ("resolution_original_tip", "Түпнұсқа ажыратымдылық"), ("resolution_fit_local_tip", "Лақал ажыратымдылыққа сыйғызу"), ("resolution_custom_tip", "Теңшеулі ажыратымдылық"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."), ("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."), ("Save as", "Басқаша сақтау"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Көшіру-тақтаға көшіру"), ("Enable remote printer", "Қашықтағы принтерді іске қосу"), ("Downloading {}", "{} жүктелуде"), @@ -773,7 +758,10 @@ 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.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."), + ("Lock canvas", "Кенепті құлыптау"), + ("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"), + ("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 3589a2fb3..4beb66593 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Failas yra identiškas nuotoliniame kompiuteryje esančiam failui."), ("show_monitors_tip", "Rodyti monitorius įrankių juostoje"), ("View Mode", "Peržiūros režimas"), - ("login_linux_tip", "Norėdami įjungti X darbalaukio seansą, turite būti prisijungę prie nuotolinės Linux paskyros."), ("verify_rustdesk_password_tip", "Įveskite kliento RustDesk slaptažodį"), - ("remember_account_tip", "Prisiminti šią paskyrą"), - ("os_account_desk_tip", "Ši paskyra naudojama norint prisijungti prie nuotolinės OS ir įgalinti darbalaukio seansą režimu headless"), - ("OS Account", "OS paskyra"), - ("another_user_login_title_tip", "Kitas vartotojas jau yra prisijungęs"), - ("another_user_login_text_tip", "Atjungti"), - ("xorg_not_found_title_tip", "Xorg nerastas"), - ("xorg_not_found_text_tip", "Prašom įdiegti Xorg"), - ("no_desktop_title_tip", "Nėra pasiekiamų nuotolinių darbalaukių"), - ("no_desktop_text_tip", "Prašom įdiegti GNOME Desktop"), ("No need to elevate", "Teisių kelti nereikia"), ("System Sound", "Sistemos garsas"), ("Default", "Numatytasis"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Kontrolinis kodas"), ("Copy Fingerprint", "Kopijuoti kontrolinį kodą"), ("no fingerprints", "Nėra kontrolinių kodų"), - ("Select a peer", "Pasirinkite įrenginį"), - ("Select peers", "Pasirinkite įrenginius"), - ("Plugins", "Papildiniai"), - ("Uninstall", "Pašalinti"), ("Update", "Atnaujinti"), - ("Enable", "Įgalinti"), - ("Disable", "Išjungti"), - ("Options", "Parinktys"), ("resolution_original_tip", "Originali skiriamoji geba"), ("resolution_fit_local_tip", "Pritaikyti prie vietinės skiriamosios gebos"), ("resolution_custom_tip", "Tinkinta skiriamoji geba"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."), ("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."), ("Save as", "Įrašyti kaip"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopijuoti į iškarpinę"), ("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"), ("Downloading {}", "Atsisiunčiama {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Tęsti"), + ("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."), + ("Lock canvas", "Užrakinti drobę"), + ("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"), + ("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index d4101d6db..2d7038f19 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Šis fails ir identisks sesijas failam."), ("show_monitors_tip", "Rādīt monitorus rīkjoslā"), ("View Mode", "Skatīšanas režīms"), - ("login_linux_tip", "Jums ir jāpiesakās attālajā Linux kontā, lai iespējotu X darbvirsmas sesiju"), ("verify_rustdesk_password_tip", "Pārbaudīt RustDesk paroli"), - ("remember_account_tip", "Atcerēties šo kontu"), - ("os_account_desk_tip", "Šis konts tiek izmantots, lai pieteiktos attālajā operētājsistēmā un iespējotu darbvirsmas sesiju fonā"), - ("OS Account", "OS konts"), - ("another_user_login_title_tip", "Cits lietotājs jau ir pieteicies"), - ("another_user_login_text_tip", "Atvienot"), - ("xorg_not_found_title_tip", "Xorg nav atrasts"), - ("xorg_not_found_text_tip", "Lūdzu, instalējiet Xorg"), - ("no_desktop_title_tip", "Nav pieejama darbvirsma"), - ("no_desktop_text_tip", "Lūdzu, instalējiet GNOME darbvirsmu"), ("No need to elevate", "Nav nepieciešams paaugstināt"), ("System Sound", "Sistēmas skaņa"), ("Default", "Noklusējums"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Pirkstu nospiedums"), ("Copy Fingerprint", "Kopēt pirkstu nospiedumu"), ("no fingerprints", "nav pirkstu nospiedumu"), - ("Select a peer", "Atlasīt līdzīgu"), - ("Select peers", "Atlasīt līdzīgus"), - ("Plugins", "Spraudņi"), - ("Uninstall", "Atinstalēt"), ("Update", "Atjaunināt"), - ("Enable", "Iespējot"), - ("Disable", "Atspējot"), - ("Options", "Opcijas"), ("resolution_original_tip", "Sākotnējā izšķirtspēja"), ("resolution_fit_local_tip", "Atbilst vietējai izšķirtspējai"), ("resolution_custom_tip", "Pielāgota izšķirtspēja"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."), ("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."), ("Save as", "Saglabāt kā"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopēt starpliktuvē"), ("Enable remote printer", "Iespējot attālo printeri"), ("Downloading {}", "Notiek {} lejupielāde"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Turpināt"), + ("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."), + ("Lock canvas", "Bloķēt audeklu"), + ("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"), + ("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index d93760b50..f7804fa0c 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", "ഓഫ് ചെയ്തു"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ഈ ഫയൽ നിലവിലുണ്ട്."), ("show_monitors_tip", "ടൂൾബാറിൽ മോണിറ്ററുകൾ കാണിക്കുക"), ("View Mode", "വ്യൂ മോഡ്"), - ("login_linux_tip", "റിമോട്ട് ലിനക്സ് സെഷനായി ലോഗിൻ ചെയ്യണം"), ("verify_rustdesk_password_tip", "RustDesk പാസ്‌വേഡ് പരിശോധിക്കുക"), - ("remember_account_tip", "ഈ അക്കൗണ്ട് ഓർമ്മിക്കുക"), - ("os_account_desk_tip", "ആക്‌സസിനായി OS അക്കൗണ്ട് ഉപയോഗിക്കുക"), - ("OS Account", "OS അക്കൗണ്ട്"), - ("another_user_login_title_tip", "മറ്റൊരു ഉപയോക്താവ് ലോഗിൻ ചെയ്തിട്ടുണ്ട്"), - ("another_user_login_text_tip", "വിച്ഛേദിച്ച ശേഷം വീണ്ടും ശ്രമിക്കുക"), - ("xorg_not_found_title_tip", "Xorg കണ്ടെത്താനായില്ല"), - ("xorg_not_found_text_tip", "ദയവായി Xorg ഇൻസ്റ്റാൾ ചെയ്യുക"), - ("no_desktop_title_tip", "ഡെസ്ക്ടോപ്പ് ലഭ്യമല്ല"), - ("no_desktop_text_tip", "ദയവായി ലിനക്സ് ഡെസ്ക്ടോപ്പ് ഇൻസ്റ്റാൾ ചെയ്യുക"), ("No need to elevate", "എലവേറ്റ് ചെയ്യേണ്ടതില്ല"), ("System Sound", "സിസ്റ്റം സൗണ്ട്"), ("Default", "ഡിഫോൾട്ട്"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ഫിംഗർപ്രിന്റ്"), ("Copy Fingerprint", "ഫിംഗർപ്രിന്റ് കോപ്പി ചെയ്യുക"), ("no fingerprints", "ഫിംഗർപ്രിന്റുകൾ ഇല്ല"), - ("Select a peer", "ഒരാളെ തിരഞ്ഞെടുക്കുക"), - ("Select peers", "തിരഞ്ഞെടുക്കുക"), - ("Plugins", "പ്ലഗിനുകൾ"), - ("Uninstall", "അൺഇൻസ്റ്റാൾ ചെയ്യുക"), ("Update", "അപ്ഡേറ്റ് ചെയ്യുക"), - ("Enable", "പ്രവർത്തനക്ഷമമാക്കുക"), - ("Disable", "പ്രവർത്തനരഹിതമാക്കുക"), - ("Options", "ഓപ്ഷനുകൾ"), ("resolution_original_tip", "ഒറിജിനൽ റെസല്യൂഷൻ"), ("resolution_fit_local_tip", "ലോക്കൽ സ്ക്രീനിന് അനുയോജ്യം"), ("resolution_custom_tip", "കസ്റ്റം റെസല്യൂഷൻ"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."), ("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"), ("Save as", "പേരിൽ സേവ് ചെയ്യുക"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"), ("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"), ("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"), @@ -773,7 +758,10 @@ 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.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."), + ("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"), + ("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"), + ("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 3cc71a96b..753db92a2 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Denne filen er identisk med motpartens fil."), ("show_monitors_tip", "Vis skjermer i verktøylinjen"), ("View Mode", "Visningsmodus"), - ("login_linux_tip", "Du må logge inn på den eksterne Linux-kontoen for å aktivere en X-skrivebordssesjon"), ("verify_rustdesk_password_tip", "Verifiser RustDesk-passord"), - ("remember_account_tip", "Husk denne kontoen"), - ("os_account_desk_tip", "Denne kontoen brukes til å logge inn på det eksterne operativsystemet og aktivere skrivebordssesjonen i hodeløs modus"), - ("OS Account", "OS-konto"), - ("another_user_login_title_tip", "En annen bruker er allerede logget inn"), - ("another_user_login_text_tip", "Koble fra"), - ("xorg_not_found_title_tip", "Xorg ikke funnet"), - ("xorg_not_found_text_tip", "Vennligst installer Xorg"), - ("no_desktop_title_tip", "Ingen skrivebordsmiljø er tilgjengelig"), - ("no_desktop_text_tip", "Vennligst installer GNOME-skrivebordet"), ("No need to elevate", "Ikke behov for elevering"), ("System Sound", "Systemlyd"), ("Default", "Standard"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtrykk"), ("Copy Fingerprint", "Kopier fingeravtrykk"), ("no fingerprints", "Ingen fingeravtrykk"), - ("Select a peer", "Velg en motpart"), - ("Select peers", "Velg motparter"), - ("Plugins", "Programtillegg"), - ("Uninstall", "Avinstaller"), ("Update", "Oppdater"), - ("Enable", "Aktiver"), - ("Disable", "Deaktiver"), - ("Options", "Alternativer"), ("resolution_original_tip", "Original oppløsning"), ("resolution_fit_local_tip", "Tilpass til lokal oppløsning"), ("resolution_custom_tip", "Tilpasset oppløsning"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."), ("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."), ("Save as", "Lagre som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopier til utklipstavlen"), ("Enable remote printer", "Aktiver fjernskriver"), ("Downloading {}", "Laster ned {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Fortsett"), + ("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."), + ("Lock canvas", "Lås lerret"), + ("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"), + ("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 61a5306c9..7a575a04c 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "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."), - ("another_user_login_text_tip", "Afzonderlijk"), - ("xorg_not_found_title_tip", "Xorg niet gevonden."), - ("xorg_not_found_text_tip", "Installeer Xorg."), - ("no_desktop_title_tip", "Er is geen desktop beschikbaar."), - ("no_desktop_text_tip", "Installeer de GNOME desktop."), ("No need to elevate", "Niet nodig om te verhogen"), ("System Sound", "Systeemgeluid"), ("Default", "Standaard"), @@ -483,14 +472,7 @@ 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"), - ("Disable", "Deactiveren"), - ("Options", "Opties"), ("resolution_original_tip", "Oorspronkelijke resolutie"), ("resolution_fit_local_tip", "Lokale resolutie aanpassen"), ("resolution_custom_tip", "Aangepaste resolutie"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), @@ -775,5 +760,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", "CIDR-notatie wordt ondersteund, bijv. 192.168.1.0/24"), ("Continue", "Doorgaan"), ("Browser didn't open? Use the url below to sign in.", "Is de browser niet geopend? Gebruik onderstaande URL om in te loggen."), + ("Lock canvas", "Canvas vergrendelen"), + ("Sync clipboard between sessions", "Klembord synchroniseren tussen sessies"), + ("sync-clipboard-between-sessions-tip", "Tekst of afbeeldingen die in één externe sessie worden gekopieerd, worden ook naar het klembord van uw andere verbonden sessies gestuurd."), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index df5c53439..8b599b2ea 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ten plik jest identyczny z plikiem na drugim komputerze."), ("show_monitors_tip", "Pokaż monitory w zasobniku"), ("View Mode", "Tylko podgląd (wyłącza możliwość interakcji)"), - ("login_linux_tip", "Musisz zalogować się na zdalne konto, by zezwolić na sesję pulpitu X"), ("verify_rustdesk_password_tip", "Weryfikuj hasło RustDesk"), - ("remember_account_tip", "Zapamiętaj to konto"), - ("os_account_desk_tip", "To konto jest używane do logowania do zdalnych systemów i włącza bezobsługowe sesje pulpitu"), - ("OS Account", "Konto systemowe"), - ("another_user_login_title_tip", "Inny użytkownik jest już zalogowany"), - ("another_user_login_text_tip", "Rozłącz"), - ("xorg_not_found_title_tip", "Nie znaleziono Xorg"), - ("xorg_not_found_text_tip", "Proszę zainstalować Xorg"), - ("no_desktop_title_tip", "Żaden pulpit nie jest dostępny"), - ("no_desktop_text_tip", "Proszę zainstalować pulpit GNOME"), ("No need to elevate", "Podniesienie uprawnień nie jest wymagane"), ("System Sound", "Dźwięk systemowy"), ("Default", "Domyślne"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Sygnatura"), ("Copy Fingerprint", "Skopiuj sygnaturę"), ("no fingerprints", "brak sygnatur"), - ("Select a peer", "Wybierz zdalne urządzenie"), - ("Select peers", "Wybierz zdalne urządzenia"), - ("Plugins", "Wtyczki"), - ("Uninstall", "Odinstaluj"), ("Update", "Aktualizuj"), - ("Enable", "Włącz"), - ("Disable", "Wyłącz"), - ("Options", "Opcje"), ("resolution_original_tip", "Oryginalna rozdzielczość"), ("resolution_fit_local_tip", "Dostosuj rozdzielczość lokalną"), ("resolution_custom_tip", "Rozdzielczość niestandardowa"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."), ("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."), ("Save as", "Zapisz jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiuj do schowka"), ("Enable remote printer", "Włącz zdalne drukowanie"), ("Downloading {}", "Pobieranie {}"), @@ -774,6 +759,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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.", ""), + ("Browser didn't open? Use the url below to sign in.", "Przeglądarka się nie otworzyła? Użyj poniższego adresu URL, aby się zalogować."), + ("Lock canvas", "Zablokuj ekran"), + ("Sync clipboard between sessions", "Synchronizuj schowek między sesjami"), + ("sync-clipboard-between-sessions-tip", "Tekst lub obrazy skopiowane w jednej sesji zdalnej są wysyłane także do schowka pozostałych połączonych sesji."), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 79420e73b..2b71cc3e2 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este ficheiro é idêntico ao do destino."), ("show_monitors_tip", "Mostrar monitores na barra de ferramentas"), ("View Mode", "Modo de visualização"), - ("login_linux_tip", "É necessário iniciar sessão na conta Linux remota para ativar uma sessão de ambiente de trabalho X"), ("verify_rustdesk_password_tip", "Verificar palavra-passe do RustDesk"), - ("remember_account_tip", "Memorizar esta conta"), - ("os_account_desk_tip", "Esta conta é usada para iniciar sessão no SO remoto e ativar a sessão de ambiente de trabalho em modo headless"), - ("OS Account", "Conta do SO"), - ("another_user_login_title_tip", "Outro utilizador já tem sessão iniciada"), - ("another_user_login_text_tip", "Desligar"), - ("xorg_not_found_title_tip", "Xorg não encontrado"), - ("xorg_not_found_text_tip", "Instale o Xorg"), - ("no_desktop_title_tip", "Não há nenhum ambiente de trabalho disponível"), - ("no_desktop_text_tip", "Instale o ambiente de trabalho GNOME"), ("No need to elevate", "Não é necessário elevar"), ("System Sound", "Som do sistema"), ("Default", "Predefinido"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão digital"), ("Copy Fingerprint", "Copiar impressão digital"), ("no fingerprints", "Sem impressões digitais"), - ("Select a peer", "Selecionar um destino"), - ("Select peers", "Selecionar destinos"), - ("Plugins", "Plugins"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Ativar"), - ("Disable", "Desativar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Ajustar à resolução local"), ("resolution_custom_tip", "Resolução personalizada"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."), ("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para a área de transferência"), ("Enable remote printer", "Ativar impressora remota"), ("Downloading {}", "A transferir {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Continuar"), + ("Browser didn't open? Use the url below to sign in.", "O navegador não abriu? Utilize o URL abaixo para iniciar sessão."), + ("Lock canvas", "Bloquear tela"), + ("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"), + ("sync-clipboard-between-sessions-tip", "O texto ou as imagens copiados numa sessão remota também são enviados para a área de transferência das suas outras sessões ligadas."), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 8d44d6140..892358a79 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Este arquivo é idêntico ao do parceiro."), ("show_monitors_tip", "Mostrar telas na barra de ferramentas"), ("View Mode", "Modo de visualização"), - ("login_linux_tip", "Você precisa fazer login na conta Linux remota para habilitar uma sessão de desktop X"), ("verify_rustdesk_password_tip", "Verifique a senha do RustDesk"), - ("remember_account_tip", "Lembrar desta conta"), - ("os_account_desk_tip", "Esta conta é usada para fazer login no Sistema Operacional remoto e habilitar a sessão da área de trabalho em headless"), - ("OS Account", "Conta do Sistema Operacional"), - ("another_user_login_title_tip", "Outro usuário já está logado"), - ("another_user_login_text_tip", "Desconectar"), - ("xorg_not_found_title_tip", "Xorg não encontrado"), - ("xorg_not_found_text_tip", "Por favor, instale o Xorg"), - ("no_desktop_title_tip", "Nenhuma área de trabalho está disponível"), - ("no_desktop_text_tip", "Por favor, instale a área de trabalho do GNOME"), ("No need to elevate", "Não há necessidade de elevar"), ("System Sound", "Som do Sistema"), ("Default", "Padrão"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Impressão Digital"), ("Copy Fingerprint", "Copiar Impressão Digital"), ("no fingerprints", "sem Impressões Digitais"), - ("Select a peer", "Selecione um parceiro"), - ("Select peers", "Selecione parceiros"), - ("Plugins", "Plugins"), - ("Uninstall", "Desinstalar"), ("Update", "Atualizar"), - ("Enable", "Habilitar"), - ("Disable", "Desabilitar"), - ("Options", "Opções"), ("resolution_original_tip", "Resolução original"), ("resolution_fit_local_tip", "Adequar à resolução local"), ("resolution_custom_tip", "Customizar resolução"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."), ("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."), ("Save as", "Salvar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para área de transferência"), ("Enable remote printer", "Habilitar impressora remota"), ("Downloading {}", "Baixando {}"), @@ -774,6 +759,9 @@ 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."), + ("Lock canvas", "Bloquear tela"), + ("Sync clipboard between sessions", "Sincronizar área de transferência entre sessões"), + ("sync-clipboard-between-sessions-tip", "Texto ou imagens copiados em uma sessão remota também são enviados para a área de transferência das suas outras sessões conectadas."), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 4499df1bd..6bf368822 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Acest fișier este identic cu cel al dispozitivului pereche."), ("show_monitors_tip", "Afișează monitoare în bara de instrumente"), ("View Mode", "Mod vizualizare"), - ("login_linux_tip", "Este necesar să te conectezi la contul de Linux de la distanță pentru a începe o sesiune cu un desktop care folosește X11"), ("verify_rustdesk_password_tip", "Verifică parola RustDesk"), - ("remember_account_tip", "Reține contul"), - ("os_account_desk_tip", "Acest cont este utilizat pentru conectarea la sistemul de operare la distanță și începerea sesiunii cu desktopul în modul fără afișaj."), - ("OS Account", "Cont OS"), - ("another_user_login_title_tip", "Un alt utilizator este deja conectat"), - ("another_user_login_text_tip", "Deconectare"), - ("xorg_not_found_title_tip", "Xorg nu a fost găsit"), - ("xorg_not_found_text_tip", "Instalează Xorg"), - ("no_desktop_title_tip", "Nu este disponibil niciun mediu desktop"), - ("no_desktop_text_tip", "Instalează mediul desktop GNOME"), ("No need to elevate", "Nu sunt necesare permisiuni de administrator"), ("System Sound", "Sunet sistem"), ("Default", "Implicit"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Amprentă digitală"), ("Copy Fingerprint", "Copiază amprenta digitală"), ("no fingerprints", "Nicio amprentă digitală"), - ("Select a peer", "Selectează un dispozitiv pereche"), - ("Select peers", "Selectează dispozitive pereche"), - ("Plugins", "Pluginuri"), - ("Uninstall", "Dezinstalează"), ("Update", "Actualizează"), - ("Enable", "Activează"), - ("Disable", "Dezactivează"), - ("Options", "Opțiuni"), ("resolution_original_tip", "Rezoluție originală"), ("resolution_fit_local_tip", "Adaptează la rezoluția locală"), ("resolution_custom_tip", "Rezoluție personalizată"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."), ("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."), ("Save as", "Salvează ca"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiază în clipboard"), ("Enable remote printer", "Activează imprimanta la distanță"), ("Downloading {}", "Se descarcă {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Continuă"), + ("Browser didn't open? Use the url below to sign in.", "Browserul nu s-a deschis? Folosește URL-ul de mai jos pentru a te conecta."), + ("Lock canvas", "Blochează ecranul"), + ("Sync clipboard between sessions", "Sincronizează clipboardul între sesiuni"), + ("sync-clipboard-between-sessions-tip", "Textul sau imaginile copiate într-o sesiune la distanță sunt trimise și în clipboardul celorlalte sesiuni conectate."), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 459549f97..54d388cbe 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", "Отключён"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Файл идентичен файлу на удалённом узле"), ("show_monitors_tip", "Показывать мониторы на панели инструментов"), ("View Mode", "Режим просмотра"), - ("login_linux_tip", "Чтобы включить сеанс рабочего стола X, необходимо войти в удалённый аккаунт Linux."), ("verify_rustdesk_password_tip", "Подтвердить пароль RustDesk"), - ("remember_account_tip", "Запомнить этот аккаунт"), - ("os_account_desk_tip", "Этот аккаунт используется для входа в удалённую ОС и включения сеанса рабочего стола в режиме headless."), - ("OS Account", "Аккаунт ОС"), - ("another_user_login_title_tip", "Другой пользователь уже вошёл в систему"), - ("another_user_login_text_tip", "Отключить"), - ("xorg_not_found_title_tip", "Xorg не найден"), - ("xorg_not_found_text_tip", "Установите Xorg"), - ("no_desktop_title_tip", "Нет доступных рабочих столов"), - ("no_desktop_text_tip", "Установите GNOME Desktop"), ("No need to elevate", "Повышение прав не требуется"), ("System Sound", "Системный звук"), ("Default", "По умолчанию"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Отпечаток"), ("Copy Fingerprint", "Копировать отпечаток"), ("no fingerprints", "отпечатки отсутствуют"), - ("Select a peer", "Выберите удалённый узел"), - ("Select peers", "Выберите удалённые узлы"), - ("Plugins", "Плагины"), - ("Uninstall", "Удалить"), ("Update", "Обновить"), - ("Enable", "Включить"), - ("Disable", "Отключить"), - ("Options", "Настройки"), ("resolution_original_tip", "Исходное разрешение"), ("resolution_fit_local_tip", "Соответствие локальному разрешению"), ("resolution_custom_tip", "Произвольное разрешение"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."), ("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."), ("Save as", "Сохранить в файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копировать в буфер обмена"), ("Enable remote printer", "Использовать удалённый принтер"), ("Downloading {}", "Скачивание"), @@ -773,7 +758,10 @@ 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.", "Браузер не открылся? Используйте ссылку ниже для входа."), + ("Lock canvas", "Заблокировать холст"), + ("Sync clipboard between sessions", "Синхронизировать буфер обмена между сеансами"), + ("sync-clipboard-between-sessions-tip", "Текст или изображения, скопированные в одном удалённом сеансе, также отправляются в буфер обмена других подключённых сеансов."), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 1ccfcf7dc..e13c07fc9 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Custu archìviu est pretzisu a su chi b'at in su dispositivu remotu."), ("show_monitors_tip", "Mustra sos ischermos in s'istanga de sos trastes"), ("View Mode", "Modalidade de visualizatzione"), - ("login_linux_tip", "Intra a su contu de Linux remotu"), ("verify_rustdesk_password_tip", "Cunfirma sa crae de RustDesk"), - ("remember_account_tip", "Ammenta custu contu"), - ("os_account_desk_tip", "Custu contu s'impreat pro intrare a su sistema operativu remotu e ativare sa sessione de s'elaboradore in modalidade non presidiada."), - ("OS Account", "Contu sistema operativu"), - ("another_user_login_title_tip", "Un'àteru utente at giai fatu s'atzessu."), - ("another_user_login_text_tip", "Separadu"), - ("xorg_not_found_title_tip", "Xorg no atzapadu."), - ("xorg_not_found_text_tip", "Installa Xorg."), - ("no_desktop_title_tip", "Non b'at perunu ambiente de elaboradore a disponimentu."), - ("no_desktop_text_tip", "Installa s'ambiente de elaboradore GNOME."), ("No need to elevate", "Crèschida de sos privilègios non pedida"), ("System Sound", "Dispositivu àudio de sistema"), ("Default", "Predefinida"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Firma digitale"), ("Copy Fingerprint", "Còpia firma digitale"), ("no fingerprints", "Peruna firma digitale"), - ("Select a peer", "Seletziona su dispositivu remotu"), - ("Select peers", "Seletziona sos dispositivos remotos"), - ("Plugins", "Cumplementos"), - ("Uninstall", "Disinstalla"), ("Update", "Atualiza"), - ("Enable", "Abìlita"), - ("Disable", "Disabìlita"), - ("Options", "Optziones"), ("resolution_original_tip", "Risolutzione originale"), ("resolution_fit_local_tip", "Adata sa risolutzione locale"), ("resolution_custom_tip", "Risolutzione personalizada"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."), ("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."), ("Save as", "Sarva comente"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Còpia in punta de billete"), ("Enable remote printer", "Abìlita imprentadora remota"), ("Downloading {}", "Iscarrighende {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Sighi"), + ("Browser didn't open? Use the url below to sign in.", "Non s'est abertu su navigadore? Imprea s'URL inoghe in suta pro intrare."), + ("Lock canvas", "Bloca sa tela"), + ("Sync clipboard between sessions", "Sincroniza sa punta de billete intre is sessiones"), + ("sync-clipboard-between-sessions-tip", "Su testu o is immàgines copiadas in una sessione remota sunt imbiadas fintzas a sa punta de billete de is àteras sessiones connètidas."), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 3d4993115..1795c97ac 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ý"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tento súbor je identický so súborom partnera."), ("show_monitors_tip", "Zobraziť monitory na paneli nástrojov"), ("View Mode", "Režim zobrazenia"), - ("login_linux_tip", "Ak chcete povoliť reláciu Desktop X, musíte sa prihlásiť do vzdialeného konta Linuxu."), ("verify_rustdesk_password_tip", "Overenie hesla RustDesk"), - ("remember_account_tip", "Zapamätať si tento účet"), - ("os_account_desk_tip", "Toto konto sa používa na prihlásenie do vzdialeného operačného systému a na povolenie relácie pracovnej plochy v režime headless."), - ("OS Account", "Účet operačného systému"), - ("another_user_login_title_tip", "Ďalší používateľ je už prihlásený"), - ("another_user_login_text_tip", "Odpojiť"), - ("xorg_not_found_title_tip", "Xorg nebol nájdený"), - ("xorg_not_found_text_tip", "Prosím, nainštalujte Xorg"), - ("no_desktop_title_tip", "Nie je k dispozícii žiadna plocha"), - ("no_desktop_text_tip", "Nainštalujte si prostredie GNOME"), ("No need to elevate", "Navýšenie nie je potrebné"), ("System Sound", "Systémový zvuk"), ("Default", "Predvolené"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Odtlačok prsta"), ("Copy Fingerprint", "Kopírovať odtlačok prsta"), ("no fingerprints", "žiadne odtlačky prstov"), - ("Select a peer", "Výber partnera"), - ("Select peers", "Výber partnerov"), - ("Plugins", "Pluginy"), - ("Uninstall", "Odinštalovať"), ("Update", "Aktualizovať"), - ("Enable", "Povoliť"), - ("Disable", "Zakázať"), - ("Options", "Možnosti"), ("resolution_original_tip", "Pôvodné rozlíšenie"), ("resolution_fit_local_tip", "Prispôsobiť miestne rozlíšenie"), ("resolution_custom_tip", "Vlastné rozlíšenie"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."), ("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."), ("Save as", "Uložiť ako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovať do schránky"), ("Enable remote printer", "Povoliť vzdialenú tlačiareň"), ("Downloading {}", "Sťahuje sa {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Pokračovať"), + ("Browser didn't open? Use the url below to sign in.", "Neotvoril sa prehliadač? Na prihlásenie použite URL nižšie."), + ("Lock canvas", "Uzamknúť zobrazenie"), + ("Sync clipboard between sessions", "Synchronizovať schránku medzi reláciami"), + ("sync-clipboard-between-sessions-tip", "Text alebo obrázky skopírované v jednej vzdialenej relácii sa odošlú aj do schránky ostatných pripojených relácií."), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs old mode 100755 new mode 100644 index 10fc5d909..694297a46 --- 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Datoteka je enaka partnerjevi"), ("show_monitors_tip", "Prikaži monitorje v orodni vrstici"), ("View Mode", "Način prikazovanja"), - ("login_linux_tip", "Prijaviti se morate v oddaljeni Linux račun in omogočiti namizno sejo X."), ("verify_rustdesk_password_tip", "Preveri geslo za RustDesk"), - ("remember_account_tip", "Zapomni si ta račun"), - ("os_account_desk_tip", "Ta račun se uporabi za prijavo v oddaljeni sistem in omogči namizno sejo v napravi brez monitorja."), - ("OS Account", "Račun operacijskega sistema"), - ("another_user_login_title_tip", "Prijavljen je že drug uporabnik"), - ("another_user_login_text_tip", "Prekini"), - ("xorg_not_found_title_tip", "Xorg ni najden"), - ("xorg_not_found_text_tip", "Namestite Xorg"), - ("no_desktop_title_tip", "Namizno okolje ni na voljo"), - ("no_desktop_text_tip", "Namestite GNOME"), ("No need to elevate", "Povzdig pravic ni potreben"), ("System Sound", "Sistemski zvok"), ("Default", "Privzeto"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Prstni odtis"), ("Copy Fingerprint", "Kopiraj prstni odtis"), ("no fingerprints", "ni prstnega odtisa"), - ("Select a peer", "Izberite partnerja"), - ("Select peers", "Izberite partnerje"), - ("Plugins", "Vključki"), - ("Uninstall", "Odstrani"), ("Update", "Posodobi"), - ("Enable", "Omogoči"), - ("Disable", "Onemogoči"), - ("Options", "Možnosti"), ("resolution_original_tip", "Izvirna ločljivost"), ("resolution_fit_local_tip", "Prilagodi lokalni ločljivosti"), ("resolution_custom_tip", "Ločljivost po meri"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."), ("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."), ("Save as", "Shrani kot"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj v odložišče"), ("Enable remote printer", "Omogoči oddaljeni tiskalnik"), ("Downloading {}", "Prenašanje {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Nadaljuj"), + ("Browser didn't open? Use the url below to sign in.", "Brskalnik se ni odprl? Za prijavo uporabite spodnji URL."), + ("Lock canvas", "Zakleni platno"), + ("Sync clipboard between sessions", "Sinhroniziraj odložišče med sejami"), + ("sync-clipboard-between-sessions-tip", "Besedilo ali slike, kopirane v eni oddaljeni seji, se pošljejo tudi v odložišče vaših drugih povezanih sej."), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 91f5d4c7a..3263993f3 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ky skedar është identik me atë të peer-it."), ("show_monitors_tip", "Shfaq monitorët në shiritin e veglave"), ("View Mode", "Modaliteti i pamjes"), - ("login_linux_tip", "Duhet të hyni në llogarinë Linux në distancë për të aktivizuar një seancë desktopi X"), ("verify_rustdesk_password_tip", "Verifiko fjalëkalimin e RustDesk"), - ("remember_account_tip", "Mbaj mend këtë llogari"), - ("os_account_desk_tip", "Kjo llogari përdoret për të hyrë në OS-në në distancë dhe për të aktivizuar seancën e desktopit pa ekran"), - ("OS Account", "Llogaria e OS"), - ("another_user_login_title_tip", "Një përdorues tjetër ka hyrë tashmë"), - ("another_user_login_text_tip", "Shkëput"), - ("xorg_not_found_title_tip", "Xorg nuk u gjet"), - ("xorg_not_found_text_tip", "Ju lutemi instaloni Xorg"), - ("no_desktop_title_tip", "Nuk ka asnjë mjedis desktopi të disponueshëm"), - ("no_desktop_text_tip", "Ju lutemi instaloni desktopin GNOME"), ("No need to elevate", "Nuk ka nevojë për ngritje privilegjesh"), ("System Sound", "Tingulli i sistemit"), ("Default", "I parazgjedhur"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Gjurma e gishtit"), ("Copy Fingerprint", "Kopjo gjurmën e gishtit"), ("no fingerprints", "Nuk ka gjurmë gishtash"), - ("Select a peer", "Zgjidh një peer"), - ("Select peers", "Zgjidh peer-at"), - ("Plugins", "Shtojcat"), - ("Uninstall", "Çinstalo"), ("Update", "Përditëso"), - ("Enable", "Aktivizo"), - ("Disable", "Çaktivizo"), - ("Options", "Opsionet"), ("resolution_original_tip", "Rezolucioni origjinal"), ("resolution_fit_local_tip", "Përshtat me rezolucionin lokal"), ("resolution_custom_tip", "Rezolucion i personalizuar"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."), ("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."), ("Save as", "Ruaj si"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopjo te clipboard"), ("Enable remote printer", "Aktivizo printerin në distancë"), ("Downloading {}", "Duke shkarkuar {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Vazhdo"), + ("Browser didn't open? Use the url below to sign in.", "Shfletuesi nuk u hap? Përdorni URL-në më poshtë për të hyrë."), + ("Lock canvas", "Kyç canvas"), + ("Sync clipboard between sessions", "Sinkronizo clipboard-in midis sesioneve"), + ("sync-clipboard-between-sessions-tip", "Teksti ose imazhet e kopjuara në një sesion të largët dërgohen edhe në clipboard-in e sesioneve të tjera të lidhura."), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index b79eccf5b..31987bf31 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Ova datoteka je identična sa onom kod klijenta."), ("show_monitors_tip", "Prikaži monitore u traci alata"), ("View Mode", "Režim prikaza"), - ("login_linux_tip", "Potrebno je da se prijavite na udaljeni Linux nalog da biste omogućili X desktop sesiju"), ("verify_rustdesk_password_tip", "Potvrdi RustDesk lozinku"), - ("remember_account_tip", "Zapamti ovaj nalog"), - ("os_account_desk_tip", "Ovaj nalog se koristi za prijavu na udaljeni OS i omogućavanje desktop sesije u headless režimu"), - ("OS Account", "OS nalog"), - ("another_user_login_title_tip", "Drugi korisnik je već prijavljen"), - ("another_user_login_text_tip", "Prekini vezu"), - ("xorg_not_found_title_tip", "Xorg nije pronađen"), - ("xorg_not_found_text_tip", "Molimo instalirajte Xorg"), - ("no_desktop_title_tip", "Nijedno desktop okruženje nije dostupno"), - ("no_desktop_text_tip", "Molimo instalirajte GNOME desktop"), ("No need to elevate", "Nema potrebe za podizanjem privilegija"), ("System Sound", "Sistemski zvuk"), ("Default", "Podrazumevano"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Otisak"), ("Copy Fingerprint", "Kopiraj otisak"), ("no fingerprints", "Nema otisaka"), - ("Select a peer", "Izaberi klijenta"), - ("Select peers", "Izaberi klijente"), - ("Plugins", "Dodaci"), - ("Uninstall", "Deinstaliraj"), ("Update", "Ažuriraj"), - ("Enable", "Omogući"), - ("Disable", "Onemogući"), - ("Options", "Opcije"), ("resolution_original_tip", "Originalna rezolucija"), ("resolution_fit_local_tip", "Prilagodi lokalnoj rezoluciji"), ("resolution_custom_tip", "Prilagođena rezolucija"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."), ("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."), ("Save as", "Sačuvaj kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u clipboard"), ("Enable remote printer", "Omogući udaljeni štampač"), ("Downloading {}", "Preuzimanje {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Nastavi"), + ("Browser didn't open? Use the url below to sign in.", "Pregledač se nije otvorio? Za prijavu koristite URL ispod."), + ("Lock canvas", "Zaključaj pozadinu"), + ("Sync clipboard between sessions", "Sinhronizuj klipbord između sesija"), + ("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirane u jednoj udaljenoj sesiji šalju se i u klipbord vaših ostalih povezanih sesija."), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 79dd316cd..45cc4f030 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Den här filen är identisk med klientens."), ("show_monitors_tip", "Visa skärmar i verktygsfältet"), ("View Mode", "Visningsläge"), - ("login_linux_tip", "Du måste logga in på Linux-fjärrkontot för att aktivera en X-skrivbordssession"), ("verify_rustdesk_password_tip", "Verifiera RustDesk-lösenord"), - ("remember_account_tip", "Kom ihåg detta konto"), - ("os_account_desk_tip", "Detta konto används för att logga in på fjärroperativsystemet och aktivera skrivbordssessionen i obevakat läge"), - ("OS Account", "OS-konto"), - ("another_user_login_title_tip", "En annan användare är redan inloggad"), - ("another_user_login_text_tip", "Koppla ifrån"), - ("xorg_not_found_title_tip", "Xorg hittades inte"), - ("xorg_not_found_text_tip", "Installera Xorg"), - ("no_desktop_title_tip", "Ingen skrivbordsmiljö är tillgänglig"), - ("no_desktop_text_tip", "Installera GNOME-skrivbordet"), ("No need to elevate", "Ingen behörighetshöjning behövs"), ("System Sound", "Systemljud"), ("Default", "Standard"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Fingeravtryck"), ("Copy Fingerprint", "Kopiera fingeravtryck"), ("no fingerprints", "inga fingeravtryck"), - ("Select a peer", "Välj en klient"), - ("Select peers", "Välj klienter"), - ("Plugins", "Plugin"), - ("Uninstall", "Avinstallera"), ("Update", "Uppdatera"), - ("Enable", "Aktivera"), - ("Disable", "Inaktivera"), - ("Options", "Inställningar"), ("resolution_original_tip", "Ursprunglig upplösning"), ("resolution_fit_local_tip", "Anpassa till lokal upplösning"), ("resolution_custom_tip", "Anpassad upplösning"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."), ("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."), ("Save as", "Spara som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kppiera till urklipp"), ("Enable remote printer", "Aktivera fjärrskrivare"), ("Downloading {}", "Laddar ner {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Fortsätt"), + ("Browser didn't open? Use the url below to sign in.", "Öppnades inte webbläsaren? Använd URL:en nedan för att logga in."), + ("Lock canvas", "Lås canvas"), + ("Sync clipboard between sessions", "Synkronisera urklipp mellan sessioner"), + ("sync-clipboard-between-sessions-tip", "Text eller bilder som kopieras i en fjärrsession skickas även till urklipp i dina andra anslutna sessioner."), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 376af972e..2a0e1e0f7 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", "அணைக்கப்பட்டது"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ஒரே_மாதிரியான_கோப்பு_குறிப்பு"), ("show_monitors_tip", "மானிட்டர்களை_காட்டு_குறிப்பு"), ("View Mode", "காட்சி முறை"), - ("login_linux_tip", "லினக்ஸ்_உள்நுழைவு_குறிப்பு"), ("verify_rustdesk_password_tip", "rustdesk_கடவுச்சொல்_சரிபார்ப்பு_குறிப்பு"), - ("remember_account_tip", "கணக்கை_நினைவில்_கொள்_குறிப்பு"), - ("os_account_desk_tip", "os_கணக்கு_டெஸ்க்_குறிப்பு"), - ("OS Account", "OS கணக்கு"), - ("another_user_login_title_tip", "மற்றொரு_பயனர்_உள்நுழைவு_தலைப்பு_குறிப்பு"), - ("another_user_login_text_tip", "மற்றொரு_பயனர்_உள்நுழைவு_உரை_குறிப்பு"), - ("xorg_not_found_title_tip", "xorg_காணப்படவில்லை_தலைப்பு_குறிப்பு"), - ("xorg_not_found_text_tip", "xorg_காணப்படவில்லை_உரை_குறிப்பு"), - ("no_desktop_title_tip", "டெஸ்க்டாப்_இல்லை_தலைப்பு_குறிப்பு"), - ("no_desktop_text_tip", "டெஸ்க்டாப்_இல்லை_உரை_குறிப்பு"), ("No need to elevate", "உயர்த்த தேவையில்லை"), ("System Sound", "சிஸ்டம் ஒலி"), ("Default", "இயல்புநிலை"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "கைரேகை"), ("Copy Fingerprint", "கைரேகை நகல்"), ("no fingerprints", "கைரேகைகள் இல்லை"), - ("Select a peer", "பியர் தேர்வு"), - ("Select peers", "பியர்கள் தேர்வு"), - ("Plugins", "இணைப்புகள்"), - ("Uninstall", "நிறுவல் நீக்கு"), ("Update", "புதுப்பி"), - ("Enable", "இயக்கு"), - ("Disable", "அணை"), - ("Options", "விருப்பங்கள்"), ("resolution_original_tip", "அசல் தெளிவுத்திறன்"), ("resolution_fit_local_tip", "உள்ளூர் பொருத்தம்"), ("resolution_custom_tip", "தனிப்பயன் தெளிவுத்திறன்"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ஸ்கிரீன்ஷாட்_இணைக்கப்பட்ட_திரை_ஆதரவற்ற_குறிப்பு"), ("screenshot-action-tip", "ஸ்கிரீன்ஷாட்_செயல்_குறிப்பு"), ("Save as", "இப்படி சேமி"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "கிளிப்போர்டில் நகல்"), ("Enable remote printer", "தொலை அச்சுப்பொறி இயக்கு"), ("Downloading {}", "{} பதிவிறக்குகிறது"), @@ -773,7 +758,10 @@ 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.", "உலாவி திறக்கவில்லையா? உள்நுழைய கீழே உள்ள URL ஐப் பயன்படுத்தவும்."), + ("Lock canvas", "கேன்வாஸைப் பூட்டு"), + ("Sync clipboard between sessions", "அமர்வுகளுக்கு இடையே கிளிப்போர்டை ஒத்திசைக்கவும்"), + ("sync-clipboard-between-sessions-tip", "ஒரு தொலை அமர்வில் நகலெடுக்கப்பட்ட உரை அல்லது படங்கள் உங்கள் பிற இணைக்கப்பட்ட அமர்வுகளின் கிளிப்போர்டுக்கும் அனுப்பப்படும்."), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index f16cf1ebc..feab1b71e 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", ""), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", ""), ("show_monitors_tip", ""), ("View Mode", ""), - ("login_linux_tip", ""), ("verify_rustdesk_password_tip", ""), - ("remember_account_tip", ""), - ("os_account_desk_tip", ""), - ("OS Account", ""), - ("another_user_login_title_tip", ""), - ("another_user_login_text_tip", ""), - ("xorg_not_found_title_tip", ""), - ("xorg_not_found_text_tip", ""), - ("no_desktop_title_tip", ""), - ("no_desktop_text_tip", ""), ("No need to elevate", ""), ("System Sound", ""), ("Default", ""), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", ""), ("Copy Fingerprint", ""), ("no fingerprints", ""), - ("Select a peer", ""), - ("Select peers", ""), - ("Plugins", ""), - ("Uninstall", ""), ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), ("resolution_original_tip", ""), ("resolution_fit_local_tip", ""), ("resolution_custom_tip", ""), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", ""), ("screenshot-action-tip", ""), ("Save as", ""), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", ""), ("Enable remote printer", ""), ("Downloading {}", ""), @@ -775,5 +760,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("whitelist_cidr_tip", ""), ("Continue", ""), ("Browser didn't open? Use the url below to sign in.", ""), + ("Lock canvas", ""), + ("Sync clipboard between sessions", ""), + ("sync-clipboard-between-sessions-tip", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index bd87cf5a7..d261884b3 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", "ปิด"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "ไฟล์นี้เหมือนกับไฟล์ของอีกฝั่ง"), ("show_monitors_tip", "แสดงหน้าจอในแถบเครื่องมือ"), ("View Mode", "โหมดการดู"), - ("login_linux_tip", "คุณจำเป็นจะต้องเข้าสู่ระบบไปยังบัญชีลินุกซ์ปลายทางเพื่อใช้งานเดสก์ท็อปเซสชัน X"), ("verify_rustdesk_password_tip", "ยืนยันความถูกต้องรหัสผ่านของ RustDesk"), - ("remember_account_tip", "จดจำบัญชีนี้"), - ("os_account_desk_tip", "บัญชีนี้จะถูกใช้ในการเข้าสู่ระบบเครื่องปลายทางและเริ่มใช้งานเดสก์ท็อปเซสชันแบบ headless"), - ("OS Account", "บัญชีระบบปฏิบัติการ"), - ("another_user_login_title_tip", "ผู้ใช้งานอื่นเข้าสู่ระบบอยู่แล้ว"), - ("another_user_login_text_tip", "ยกเลิกการเชื่อมต่อ"), - ("xorg_not_found_title_tip", "ไม่พบ Xorg"), - ("xorg_not_found_text_tip", "กรุณาติดตั้ง Xorg"), - ("no_desktop_title_tip", "ไม่มีหน้าเดสก์ท็อปที่ใช้งานได้"), - ("no_desktop_text_tip", "กรุณาติดตั้ง GNOME เดสกท็อป"), ("No need to elevate", "ไม่จำเป็นต้องยกระดับสิทธิ์การใช้งาน"), ("System Sound", "เสียงของระบบ"), ("Default", "ค่าเริ่มต้น"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "ลายนิ้วมือ"), ("Copy Fingerprint", "คัดลอกลายนิ้วมือ"), ("no fingerprints", "ไม่มีลายนิ้วมือ"), - ("Select a peer", "เลือกผู้ใช้งาน"), - ("Select peers", "เลือกผู้ใช้งาน"), - ("Plugins", "ปลั๊กอิน"), - ("Uninstall", "ถอนการติดตั้ง"), ("Update", "อัปเดต"), - ("Enable", "เปิดใช้งาน"), - ("Disable", "ปิดใช้งาน"), - ("Options", "ตัวเลือก"), ("resolution_original_tip", "ความละเอียดดั้งเดิม"), ("resolution_fit_local_tip", "ความละเอียดตามต้นทาง"), ("resolution_custom_tip", "ความละเอียดแบบกำหนดเอง"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ขณะนี้ยังไม่รองรับการรวมภาพหน้าจอจากหลายจอแสดงผล กรุณาสลับไปใช้จอแสดงผลเดียวแล้วลองใหม่"), ("screenshot-action-tip", "กรุณาเลือกวิธีดำเนินการต่อกับภาพหน้าจอ"), ("Save as", "บันทึกเป็น"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "คัดลอกไปยังคลิปบอร์ด"), ("Enable remote printer", "เปิดใช้งานเครื่องพิมพ์ระยะไกล"), ("Downloading {}", "กำลังดาวน์โหลด {}"), @@ -773,7 +758,10 @@ 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.", "เบราว์เซอร์ไม่เปิดใช่ไหม? ใช้ URL ด้านล่างเพื่อเข้าสู่ระบบ"), + ("Lock canvas", "ล็อคแคนวาส"), + ("Sync clipboard between sessions", "ซิงค์คลิปบอร์ดระหว่างเซสชัน"), + ("sync-clipboard-between-sessions-tip", "ข้อความหรือรูปภาพที่คัดลอกในเซสชันระยะไกลหนึ่งจะถูกส่งไปยังคลิปบอร์ดของเซสชันอื่นที่เชื่อมต่ออยู่ด้วย"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 2925ce792..4e5f1bbca 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ı"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Bu dosya, cihazın dosyası ile aynıdır."), ("show_monitors_tip", "Monitörleri araç çubuğunda göster"), ("View Mode", "Görünüm Modu"), - ("login_linux_tip", "X masaüstü oturumu başlatmak için uzaktaki Linux hesabına giriş yapmanız gerekiyor"), ("verify_rustdesk_password_tip", "RustDesk parolasını doğrulayın"), - ("remember_account_tip", "Bu hesabı hatırla"), - ("os_account_desk_tip", "Bu hesap, uzaktaki işletim sistemine giriş yapmak ve başsız masaüstü oturumunu etkinleştirmek için kullanılır."), - ("OS Account", "İşletim Sistemi Hesabı"), - ("another_user_login_title_tip", "Başka bir kullanıcı zaten oturum açtı"), - ("another_user_login_text_tip", "Bağlantıyı Kapat"), - ("xorg_not_found_title_tip", "Xorg bulunamadı"), - ("xorg_not_found_text_tip", "Lütfen Xorg'u yükleyin"), - ("no_desktop_title_tip", "Masaüstü mevcut değil"), - ("no_desktop_text_tip", "Lütfen GNOME masaüstünü yükleyin"), ("No need to elevate", "Yükseltmeye gerek yok"), ("System Sound", "Sistem Sesi"), ("Default", "Varsayılan"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Parmak İzi"), ("Copy Fingerprint", "Parmak İzini Kopyala"), ("no fingerprints", "parmak izi yok"), - ("Select a peer", "Bir cihaz seçin"), - ("Select peers", "Cihazları seçin"), - ("Plugins", "Eklentiler"), - ("Uninstall", "Kaldır"), ("Update", "Güncelle"), - ("Enable", "Etkinleştir"), - ("Disable", "Devre Dışı Bırak"), - ("Options", "Seçenekler"), ("resolution_original_tip", "Orijinal çözünürlük"), ("resolution_fit_local_tip", "Yerel çözünürlüğe sığdır"), ("resolution_custom_tip", "Özel çözünürlük"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Birden fazla ekranın ekran görüntülerinin birleştirilmesi şu anda desteklenmiyor. Lütfen tek bir ekrana geçin ve tekrar deneyin."), ("screenshot-action-tip", "Lütfen ekran görüntüsüyle nasıl devam edeceğinizi seçin."), ("Save as", "Farklı kaydet"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Panoya kopyala"), ("Enable remote printer", "Uzak yazıcıyı etkinleştir"), ("Downloading {}", "{} indiriliyor"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Devam et"), + ("Browser didn't open? Use the url below to sign in.", "Tarayıcı açılmadı mı? Giriş yapmak için aşağıdaki URL'yi kullanın."), + ("Lock canvas", "Tuvali kilitle"), + ("Sync clipboard between sessions", "Oturumlar arasında panoyu senkronize et"), + ("sync-clipboard-between-sessions-tip", "Bir uzak oturumda kopyalanan metin veya görseller, bağlı diğer oturumlarınızın panosuna da gönderilir."), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 0401d80b7..75639e65d 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", "已關閉"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "此檔案與對方的檔案一致。"), ("show_monitors_tip", "在工具列中顯示顯示器"), ("View Mode", "瀏覽模式"), - ("login_linux_tip", "需要登入到遠端 Linux 使用者帳戶才能啟用 X 桌面環境"), ("verify_rustdesk_password_tip", "驗證 RustDesk 密碼"), - ("remember_account_tip", "記住此使用者帳戶"), - ("os_account_desk_tip", "此使用者帳戶將用於登入遠端作業系統並啟用無頭模式 (headless mode) 的桌面連線"), - ("OS Account", "作業系統使用者帳戶"), - ("another_user_login_title_tip", "另一個使用者已經登入"), - ("another_user_login_text_tip", "斷開連線"), - ("xorg_not_found_title_tip", "找不到 Xorg"), - ("xorg_not_found_text_tip", "請安裝 Xorg"), - ("no_desktop_title_tip", "沒有可用的桌面環境"), - ("no_desktop_text_tip", "請安裝 GNOME 桌面"), ("No need to elevate", "不需要提升權限"), ("System Sound", "系統音效"), ("Default", "預設"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "指紋"), ("Copy Fingerprint", "複製指紋"), ("no fingerprints", "沒有指紋"), - ("Select a peer", "選擇夥伴"), - ("Select peers", "選擇夥伴"), - ("Plugins", "外掛程式"), - ("Uninstall", "解除安裝"), ("Update", "更新"), - ("Enable", "啟用"), - ("Disable", "停用"), - ("Options", "選項"), ("resolution_original_tip", "原始解析度"), ("resolution_fit_local_tip", "調整成本機解析度"), ("resolution_custom_tip", "自訂解析度"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"), ("screenshot-action-tip", "請選擇要如何處理這張截圖。"), ("Save as", "另存為"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "複製到剪貼簿"), ("Enable remote printer", "啟用遠端列印"), ("Downloading {}", "正在下載 {} 並安裝新版本。"), @@ -773,7 +758,10 @@ 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.", "瀏覽器未開啟?請使用下方網址登入。"), + ("Lock canvas", "鎖定畫布"), + ("Sync clipboard between sessions", "在工作階段間同步剪貼簿"), + ("sync-clipboard-between-sessions-tip", "在一個遠端工作階段中複製的文字或圖片也會傳送到其他已連線工作階段的剪貼簿。"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 7e55426d1..97c56ba86 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", "Вимкнений"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Цей файл ідентичний з тим, що на вузлі"), ("show_monitors_tip", "Показувати монітори на панелі інструментів"), ("View Mode", "Режим перегляду"), - ("login_linux_tip", "Вам необхідно увійти у віддалений обліковий запис Linux, щоб увімкнути стільничний сеанс X"), ("verify_rustdesk_password_tip", "Перевірте пароль RustDesk"), - ("remember_account_tip", "Запамʼятати цей обліковий запис"), - ("os_account_desk_tip", "Цей обліковий запис використовується для входу до віддаленої ОС та вмикання сеансу стільниці в режимі без графічного інтерфейсу"), - ("OS Account", "Користувач ОС"), - ("another_user_login_title_tip", "Інший користувач вже в системі"), - ("another_user_login_text_tip", "Відʼєднатися"), - ("xorg_not_found_title_tip", "Xorg не знайдено"), - ("xorg_not_found_text_tip", "Будь ласка, встановіть Xorg"), - ("no_desktop_title_tip", "Жодне стільничне середовище не доступне"), - ("no_desktop_text_tip", "Будь ласка, встановіть стільничне середовище GNOME"), ("No need to elevate", "Немає потреби в розширенні прав"), ("System Sound", "Системний звук"), ("Default", "Типово"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Відбитки пальців"), ("Copy Fingerprint", "Копіювати відбитки пальців"), ("no fingerprints", "немає відбитків пальців"), - ("Select a peer", "Оберіть віддалений пристрій"), - ("Select peers", "Оберіть віддалені пристрої"), - ("Plugins", "Плагіни"), - ("Uninstall", "Видалити"), ("Update", "Оновити"), - ("Enable", "Увімкнути"), - ("Disable", "Вимкнути"), - ("Options", "Опції"), ("resolution_original_tip", "Початкова роздільна здатність"), ("resolution_fit_local_tip", "Припасувати поточну роздільну здатність"), ("resolution_custom_tip", "Користувацька роздільна здатність"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Об'єднання знімків кількох дисплеїв наразі не підтримується. Перейдіть на один дисплей і спробуйте знову."), ("screenshot-action-tip", "Виберіть, що робити зі знімком екрана."), ("Save as", "Зберегти як"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скопіювати до буфера обміну"), ("Enable remote printer", "Увімкнути віддалений принтер"), ("Downloading {}", "Завантаження {}"), @@ -773,7 +758,10 @@ 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.", "Браузер не відкрився? Скористайтеся посиланням нижче, щоб увійти."), + ("Lock canvas", "Блокування полотна"), + ("Sync clipboard between sessions", "Синхронізувати буфер обміну між сеансами"), + ("sync-clipboard-between-sessions-tip", "Текст або зображення, скопійовані в одному віддаленому сеансі, також надсилаються до буфера обміну інших підключених сеансів."), ].iter().cloned().collect(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs new file mode 100644 index 000000000..5f6e1c235 --- /dev/null +++ b/src/lang/ur.rs @@ -0,0 +1,750 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "حالت"), + ("Your Desktop", "آپ کا ڈیسک ٹاپ"), + ("desk_tip", ""), + ("Password", "پاس ورڈ"), + ("Ready", "تیار"), + ("Established", "قائم کیا گیا"), + ("connecting_status", "کنیکٹنگ_سٹیٹس"), + ("Enable service", "سروس کو فعال کریں"), + ("Start service", "سروس شروع کریں"), + ("Service is running", "سروس چل رہی ہے"), + ("Service is not running", "سروس نہیں چل رہی ہے"), + ("not_ready_status", ""), + ("Control Remote Desktop", "ریموٹ ڈیسک ٹاپ کو کنٹرول کریں"), + ("Transfer file", "فائل منتقل کریں"), + ("Connect", "کنیکٹ کریں"), + ("Recent sessions", "حالیہ سیشنز"), + ("Address book", "پتہ کتاب"), + ("Confirmation", "تصدیق"), + ("TCP tunneling", "TCP ٹنلینگ"), + ("Remove", "ہٹائیں"), + ("Refresh random password", "بے ترتیب پاس ورڈ ریفریش کریں"), + ("Set your own password", "اپنا پاس ورڈ خود سیٹ کریں"), + ("Enable keyboard/mouse", "ماوس/کی بورڈ کو فعال کریں"), + ("Enable clipboard", "کلپ بورڈ کو فعال کریں"), + ("Enable file transfer", "فائل ٹرانسفر کو فعال کریں"), + ("Enable TCP tunneling", "TCP ٹنلینگ کو فعال کریں"), + ("IP Whitelisting", "IP وائٹ لسٹنگ"), + ("ID/Relay Server", "ID/ریلے سرور"), + ("Import server config", "سرور کی تشکیل درآمد کریں"), + ("Export Server Config", "سرور کی تشکیل برآمد کریں"), + ("Import server configuration successfully", "سرور کی تشکیل کامیابی سے درآمد ہو گئی"), + ("Export server configuration successfully", "سرور کی تشکیل کامیابی سے برآمد ہو گئی"), + ("Invalid server configuration", "سرور کی تشکیل غلط ہے"), + ("Clipboard is empty", "کلپ بورڈ خالی ہے"), + ("Stop service", "سروس بند کریں"), + ("Change ID", "ٰID تبدیل کریں"), + ("Your new ID", "آپ کی نئی ID"), + ("length %min% to %max%", "لمبائی %min% سے %max%"), + ("starts with a letter", "حرف سے شروع ہوتا ہے"), + ("allowed characters", "اجازت یافتہ حروف"), + ("id_change_tip", ""), + ("Website", "ویب سائٹ"), + ("About", "کے بارے میں"), + ("Slogan_tip", "سلوگن_ٹپ"), + ("Privacy Statement", "رازداری کا بیان"), + ("License", "لائسنس"), + ("Mute", "خاموش"), + ("Build Date", "بنیاد کی تاریخ"), + ("Version", "ورژن"), + ("Home", "گھر"), + ("Audio Input", "آڈیو ان پٹ"), + ("Enhancements", "اضافہ"), + ("Hardware Codec", "ہارڈ ویئر کوڈیک"), + ("Adaptive bitrate", "ایڈاپٹیو بٹ ریٹ"), + ("ID Server", "ID سرور"), + ("Relay Server", "ریلے سرور"), + ("API Server", "اے پی آئی سرور"), + ("invalid_http", "غلط HTTP"), + ("Invalid IP", "غلط IP"), + ("Invalid format", "غلط فارمیٹ"), + ("server_not_support", "سرور کی حمایت نہیں ہے"), + ("Not available", "دستیاب نہیں"), + ("Too frequent", "بہت اکثر"), + ("Cancel", "منسوخ کریں"), + ("Skip", "چھوڑ دیں"), + ("Close", "بند کریں"), + ("Retry", "دوبارہ کوشش کریں"), + ("OK", "ٹھیک ہے"), + ("Password Required", "پاس ورڈ درکار ہے"), + ("Please enter your password", "اپنا پاس ورڈ درج کریں"), + ("Remember password", "پاس ورڈ یاد رکھیں"), + ("Wrong Password", "غلط پاس ورڈ"), + ("Do you want to enter again?", "کیا آپ دوبارہ اندراج کرنا چاہتے ہیں؟"), + ("Connection Error", "کنکشن کی خرابی"), + ("Error", "خرابی"), + ("Reset by the peer", "پیر کی طرف سے ری سیٹ"), + ("Connecting...", "کنیکٹ ہو رہا ہے..."), + ("Connection in progress. Please wait.", "کنکشن کیا جا رہا ہے۔ براہِ مہربانی انتظار کریں۔"), + ("Please try 1 minute later", "براہِ مہربانی 1 منٹ بعد کوشش کریں"), + ("Login Error", "لاگ ان کی خرابی"), + ("Successful", "کامیاب"), + ("Connected, waiting for image...", "کنیکٹ ہو گیا، تصویر کے لیے انتظار کر رہا ہے..."), + ("Name", "نام"), + ("Type", "ٹائپ"), + ("Modified", "تبدیل"), + ("Size", "حجم"), + ("Show Hidden Files", "خفیہ فائلیں دکھائیں"), + ("Receive", "وصول کریں"), + ("Send", "بھیجیں"), + ("Refresh File", "فائل ریفریش کریں"), + ("Local", "مقامی"), + ("Remote", "ریموٹ"), + ("Remote Computer", "ریموٹ کمپیوٹر"), + ("Local Computer", "مقامی کمپیوٹر"), + ("Confirm Delete", "حذف کی تصدیق کریں"), + ("Delete", "حذف کریں"), + ("Properties", "خصوصیات"), + ("Multi Select", "ملٹی سلیکٹ"), + ("Select All", "سب کو منتخب کریں"), + ("Unselect All", "سب کو غیر منتخب کریں"), + ("Empty Directory", "خالی ڈائرکٹری"), + ("Not an empty directory", "خالی ڈائرکٹری نہیں"), + ("Are you sure you want to delete this file?", "کیا آپ واقعی اس فائل کو حذف کرنا چاہتے ہیں؟"), + ("Are you sure you want to delete this empty directory?", "کیا آپ واقعی اس خالی ڈائرکٹری کو حذف کرنا چاہتے ہیں؟"), + ("Are you sure you want to delete the file of this directory?", "کیا آپ واقعی اس ڈائرکٹری کی فائل کو حذف کرنا چاہتے ہیں؟"), + ("Do this for all conflicts", "تمام تضادوں کے لئے یہ کرو"), + ("This is irreversible!", "ینہ واپس نہ لایا جا سکتا!"), + ("Deleting", "حذف ہو رہا ہے..."), + ("files", "فائلیں"), + ("Waiting", "انتظار کر رہا ہے"), + ("Finished", "ختم ہو گیا"), + ("Speed", "رفتار"), + ("Custom Image Quality", "کسٹم تصویر کی معیار"), + ("Privacy mode", "موڈ رازداری "), + ("Block user input", "یوزر ان پٹ کو بلاک کریں"), + ("Unblock user input", "یوزر ان پٹ کو غیر بلاک کریں"), + ("Adjust Window", "ونڈو کو سیدھا کریں"), + ("Original", "اصل"), + ("Shrink", "کم کریں"), + ("Stretch", "وسیع کریں"), + ("Scrollbar", "اسکرول بار"), + ("ScrollAuto", "آٹو اسکرول"), + ("Good image quality", "اچھی تصویر کی معیار"), + ("Balanced", "متوازن"), + ("Optimize reaction time", "ریکشن کے وقت کو بہتر بنائیں"), + ("Custom", "کسٹم"), + ("Show remote cursor", "ریموٹ کرسر دکھائیں"), + ("Show quality monitor", "معیار کا مانیٹر دکھائیں"), + ("Disable clipboard", "کلپ بورڈ کو غیر فعال کریں"), + ("Lock after session end", "سیشن ختم ہونے کے بعد لاک کریں"), + ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del داخل کریں"), + ("Insert Lock", "لاک داخل کریں"), + ("Refresh", "ریفریش کریں"), + ("ID does not exist", "ID موجود نہیں ہے"), + ("Failed to connect to rendezvous server", "رینڈوز سرور سے کنکشن کرنے میں ناکام"), + ("Please try later", "براہِ مہربانی بعد میں کوشش کریں"), + ("Remote desktop is offline", "ریموٹ ڈیسکٹاپ آف لائن ہے"), + ("Key mismatch", "کلید ممچ نہیں"), + ("Timeout", "وقت کی ختم"), + ("Failed to connect to relay server", "ریلے سرور سے کنکشن کرنے میں ناکام"), + ("Failed to connect via rendezvous server", "رینڈوز سرور سے کنکشن کرنے میں ناکام"), + ("Failed to connect via relay server", "ریلے سرور سے کنکشن کرنے میں ناکام"), + ("Failed to make direct connection to remote desktop", "ریموٹ ڈیسکٹاپ سے مستقیم کنکشن قائم کرنے میں ناکام"), + ("Set Password", "پاس ورڈ مرتب کریں"), + ("OS Password", "OS پاس ورڈ"), + ("install_tip", "انسٹال کرنے کا مشورہ"), + ("Click to upgrade", "اپگریڈ کرنے کے لئے کلک کریں"), + ("Configure", "ترتیب دینا"), + ("config_acc", ""), + ("config_screen", ""), + ("Installing ...", "انسٹال ہو رہا ہے..."), + ("Install", "انسٹال کریں"), + ("Installation", "انسٹالیشن"), + ("Installation Path", "انسٹالیشن کا راستہ"), + ("Create start menu shortcuts", "اسٹارٹ مینو شارٹ کٹس بنائیں"), + ("Create desktop icon", "ڈیسکٹاپ آئیکن بنائیں"), + ("agreement_tip", ""), + ("Accept and Install", "قبول کریں اور انسٹال کریں"), + ("End-user license agreement", "اختتامی صارف کے لائسنس کا معاہدہ"), + ("Generating ...", "بنا رہے ہیں..."), + ("Your installation is lower version.", "آپ کی تنصیب کم ورژن ہے۔"), + ("Please install the latest version.", "براہِ مہربانی تازہ ترین ورژن انسٹال کریں۔"), + ("not_close_tcp_tip", ""), + ("Listening ...", "سن رہا ہے..."), + ("Remote Host", "ریموٹ میزبان"), + ("Remote Port", "ریموٹ پورٹ"), + ("Action", "عمل"), + ("Add", "شامل کریں"), + ("Local Port", "مقامی پورٹ"), + ("Local Address", "مقامی ایڈریس"), + ("Change Local Port", "مقامی پورٹ تبدیل کریں"), + ("setup_server_tip", "سرور کی ترتیب کا مشورہ"), + ("Too short, at least 6 characters.", "بہت چھوٹا، کم از کم 6 حروف۔"), + ("The confirmation is not identical.", "تصدیق ایک جیسی نہیں ہے۔"), + ("Permissions", "اجازتیں"), + ("Accept", "قبول کریں"), + ("Dismiss", "مسترد کریں"), + ("Disconnect", "منقطع کریں"), + ("Enable file copy and paste", "فائل کاپی اور پیسٹ فعال کریں"), + ("Connected", "منسلک ہے"), + ("Direct and encrypted connection", "براہِ راست اور خفیہ کنکشن"), + ("Relayed and encrypted connection", "آگے بڑھا ہوا اور خفیہ کنکشن"), + ("Direct and unencrypted connection", "براہِ راست اور غیر خفیہ کنکشن"), + ("Relayed and unencrypted connection", "آگے بڑھا ہوا اور غیر خفیہ کنکشن"), + ("Enter Remote ID", "ریموٹ آئی ڈی درج کریں"), + ("Enter your password", "اپنا پاس ورڈ درج کریں"), + ("Logging in...", "لاگ ان ہو رہا ہے..."), + ("Enable RDP session sharing", "RDP سیشن شیئرنگ کو فعال کریں"), + ("Auto Login", "خودکار لاگ ان"), + ("Enable direct IP access", "براہِ راست IP رسائی کو فعال کریں"), + ("Rename", "نام تبدیل کریں"), + ("Space", "جگہ"), + ("Create desktop shortcut", "ڈیسک ٹاپ شارٹ کٹ بنائیں"), + ("Change Path", "راستہ تبدیل کریں"), + ("Create Folder", "فولڈر بنائیں"), + ("Please enter the folder name", "فولڈر کا نام درج کریں"), + ("Fix it", "ٹھیک کریں"), + ("Warning", "انتباہ"), + ("Login screen using Wayland is not supported", "Wayland کا استعمال کرتے ہوئے لاگ ان اسکرین کی حمایت نہیں کی جاتی ہے"), + ("Reboot required", "دوبارہ شروع کرنے کی ضرورت ہے"), + ("Unsupported display server", "غیر معاون ڈسپلے سرور"), + ("x11 expected", "x11 کی توقع ہے"), + ("Port", "پورٹ"), + ("Settings", "ترتیبات"), + ("Username", "یوزر نیم"), + ("Invalid port", "غلط پورٹ"), + ("Closed manually by the peer", "پیر کی طرف سے دستی طور پر بند"), + ("Enable remote configuration modification", "ریموٹ کنفیگریشن ترمیم کو فعال کریں"), + ("Run without install", "انسٹال کے بغیر چلائیں"), + ("Connect via relay", "ریلے کے ذریعے کنیکٹ کریں"), + ("Always connect via relay", "ہمیشہ ریلے کے ذریعے کنیکٹ کریں"), + ("whitelist_tip", ""), + ("Login", "لاگ ان کریں"), + ("Verify", "تصدیق کریں"), + ("Remember me", "یاد رکھیں"), + ("Trust this device", "اس ڈیوائس پر اعتماد کریں"), + ("Verification code", "تصدیق کوڈ"), + ("verification_tip", "تصدیق کا مشورہ"), + ("Logout", "لاگ آؤٹ"), + ("Tags", "ٹیگز"), + ("Search ID", "ID تلاش کریں"), + ("whitelist_sep", ""), + ("Add ID", "ID شامل کریں"), + ("Add Tag", "ٹیگ شامل کریں"), + ("Unselect all tags", "تمام ٹیگز کو غیر منتخب کریں"), + ("Network error", "نیٹ ورک کی خرابی"), + ("Username missed", "یوزر نیم چھوٹ گیا"), + ("Password missed", "پاس ورڈ چھوٹ گیا"), + ("Wrong credentials", "غلط اسناد"), + ("The verification code is incorrect or has expired", "تصدیق کوڈ غلط ہے یا ختم ہو چکا ہے"), + ("Edit Tag", "ٹیگ ایڈٹ کریں"), + ("Forget Password", "پاس ورڈ بھول گئے"), + ("Favorites", "پسندیدہ"), + ("Add to Favorites", "پسندیدہ میں شامل کریں"), + ("Remove from Favorites", "پسندیدہ سے ہٹائیں"), + ("Empty", "خالی"), + ("Invalid folder name", "فولڈر کا نام غلط ہے"), + ("Socks5 Proxy", "پروکسی ساکس5"), + ("Socks5/Http(s) Proxy", "ساکس5/Http(s) پروکسی"), + ("Discovered", "دریافت شدہ"), + ("install_daemon_tip", ""), + ("Remote ID", "ریموٹ ID"), + ("Paste", "چسپاں کریں"), + ("Paste here?", "یہاں چسپاں کریں؟"), + ("Are you sure to close the connection?", "کیا آپ واقعی کنکشن بند کرنا چاہتے ہیں؟"), + ("Download new version", "نیا ورژن ڈاؤن لوڈ کریں"), + ("Touch mode", "تچ موڈ"), + ("Mouse mode", "ماؤس موڈ"), + ("One-Finger Tap", "ایک انگلی سے ٹیپ"), + ("Left Mouse", "بائیں ماؤس"), + ("One-Long Tap", "ایک لمبا ٹیپ"), + ("Two-Finger Tap", "دو انگلیوں سے ٹیپ"), + ("Right Mouse", "دائیں ماؤس"), + ("One-Finger Move", "ایک انگلی سے حرکت"), + ("Double Tap & Move", "دو بار ٹیپ اور حرکت"), + ("Mouse Drag", "ماؤس گھسیٹنا"), + ("Three-Finger vertically", "تین انگلیوں سے عمودی"), + ("Mouse Wheel", "ماؤس ویل"), + ("Two-Finger Move", "دو انگلیوں سے حرکت"), + ("Canvas Move", "کینوس حرکت"), + ("Pinch to Zoom", "زوم کرنے کے لیے چوٹکی"), + ("Canvas Zoom", "کینوس زوم"), + ("Reset canvas", "کینوس ری سیٹ کریں"), + ("No permission of file transfer", "فائل ٹرانسفر کی اجازت نہیں ہے"), + ("Note", "نوٹ"), + ("Connection", "رابطہ"), + ("Share screen", "سکرین شیئر کریں"), + ("Chat", "بات چیت"), + ("Total", "کل"), + ("items", "اشیاء"), + ("Selected", "منتخب شدہ"), + ("Screen Capture", "سکرین قابض"), + ("Input Control", "درآمد کنٹرول"), + ("Audio Capture", "آڈیو قابض"), + ("Do you accept?", "کیا آپ قبول کرتے ہیں؟"), + ("Open System Setting", "سسٹم کی ترتیبات کھولیں"), + ("How to get Android input permission?", "Android کی درآمد کی اجازت کیسے حاصل کریں؟"), + ("android_input_permission_tip1", ""), + ("android_input_permission_tip2", ""), + ("android_new_connection_tip", ""), + ("android_service_will_start_tip", ""), + ("android_stop_service_tip", ""), + ("android_version_audio_tip", ""), + ("android_start_service_tip", ""), + ("android_permission_may_not_change_tip", ""), + ("Account", "کھاتا"), + ("Overwrite", "اوور رائٹ کریں"), + ("This file exists, skip or overwrite this file?", "یہ فائل موجود ہے، اس فائل کو چھوڑیں یا اوور رائٹ کریں؟"), + ("Quit", "بند کریں"), + ("Help", "مدد"), + ("Failed", "ناکام"), + ("Succeeded", "کامیاب ہو گیا"), + ("Someone turns on privacy mode, exit", "کوئی پرائیویسی موڈ آن کرتا ہے، باہر نکلیں"), + ("Unsupported", "غیر معاون"), + ("Peer denied", "ہم منسب نے انکار کر دیا"), + ("Please install plugins", "براہِ مہربانی پلگ ان انسٹال کریں"), + ("Peer exit", "ہم منسب باہر نکل گیا"), + ("Failed to turn off", "بند کرنے میں ناکام"), + ("Turned off", "بند کر دیا"), + ("Language", "زبان"), + ("Keep RustDesk background service", "RustDesk پس منظر کی خدمت کو برقرار رکھیں"), + ("Ignore Battery Optimizations", "بیٹری کی اصلاحات کو نظر انداز کریں"), + ("android_open_battery_optimizations_tip", ""), + ("Start on boot", "شروع کرنے پر شروع کریں"), + ("Start the screen sharing service on boot, requires special permissions", "بوٹ پر سکرین شیئرنگ سروس شروع کریں، خاص اجازتوں کی ضرورت ہے"), + ("Connection not allowed", "جڑنے کی اجازت نہیں ہے"), + ("Legacy mode", "میراث موڈ"), + ("Map mode", "میپ موڈ"), + ("Translate mode", "ترجمہ موڈ"), + ("Use permanent password", "مستقل پاس ورڈ استعمال کریں"), + ("Use both passwords", "دونوں پاس ورڈ استعمال کریں"), + ("Set permanent password", "مستقل پاس ورڈ مرتب کریں"), + ("Enable remote restart", "ریموٹ ری اسٹارٹ کو فعال کریں"), + ("Restart remote device", "ریموٹ ڈیوائس کو ری اسٹارٹ کریں"), + ("Are you sure you want to restart", "کیا آپ واقعی ری اسٹارٹ کرنا چاہتے ہیں؟"), + ("Restarting remote device", "ریموٹ ڈیوائس ری اسٹارٹ ہو رہی ہے"), + ("remote_restarting_tip", ""), + ("Copied", "نقل ہو گیا"), + ("Exit Fullscreen", "مکمل سکرین سے باہر نکلیں"), + ("Fullscreen", "مکمل سکرین"), + ("Mobile Actions", "موبائل کے عمل"), + ("Select Monitor", "مانیٹر منتخب کریں"), + ("Control Actions", "عمل کو قابو کریں"), + ("Display Settings", "ڈسپلے کی ترتیبات"), + ("Ratio", "تناسب"), + ("Image Quality", "تصویر کا معیار"), + ("Scroll Style", "سکرول اسٹائل"), + ("Show Toolbar", "ٹول بار دکھائیں"), + ("Hide Toolbar", "ٹول بار چھپائیں"), + ("Direct Connection", "مستقیم کنکشن"), + ("Relay Connection", "ریلے کنکشن"), + ("Secure Connection", "محفوظ کنکشن"), + ("Insecure Connection", "غیر محفوظ کنکشن"), + ("Scale original", "اصل پیمانہ"), + ("Scale adaptive", "اضافی پیمانہ"), + ("General", "جنرل"), + ("Security", "سیکورٹی"), + ("Theme", "تھیم"), + ("Dark Theme", "ڈارک تھیم"), + ("Light Theme", "لائٹ تھیم"), + ("Dark", "ڈارک"), + ("Light", "لائٹ"), + ("Follow System", "سسٹم کو اپناؤ"), + ("Enable hardware codec", "ہارڈ ویئر کوڈیک کو فعال کریں"), + ("Unlock Security Settings", "سیکورٹی ترتیبات کو اندراج کریں"), + ("Enable audio", "آڈیو کو فعال کریں"), + ("Unlock Network Settings", "نیٹ ورک ترتیبات کو اندراج کریں"), + ("Server", "سرور"), + ("Direct IP Access", "مستقیم IP رسائی"), + ("Proxy", "پراکسی"), + ("Apply", "لاگو کریں"), + ("Disconnect all devices?", "تمام ڈیوائسز سے رابطہ منقطع کریں؟"), + ("Clear", "صاف کریں"), + ("Audio Input Device", "آڈیو ان پٹ ڈیوائس"), + ("Use IP Whitelisting", "IP وہٹ لسٹنگ استعمال کریں"), + ("Network", "نیٹ ورک"), + ("Pin Toolbar", "ٹول بار پن کریں"), + ("Unpin Toolbar", "ٹول بار ان پن کریں"), + ("Recording", "ریکارڈنگ"), + ("Directory", "ڈائرکٹری"), + ("Automatically record incoming sessions", "آئندہ سیشنز کو خودکار طور پر ریکارڈ کریں"), + ("Automatically record outgoing sessions", "بہرحال سیشنز کو خودکار طور پر ریکارڈ کریں"), + ("Change", "تبدیل کریں"), + ("Start session recording", "سیشن ریکارڈنگ شروع کریں"), + ("Stop session recording", "سیشن ریکارڈنگ روک دیں"), + ("Enable recording session", "ریکارڈنگ سیشن کو فعال کریں"), + ("Enable LAN discovery", "LAN کی دریافت کو فعال کریں"), + ("Deny LAN discovery", "LAN کی دریافت کو رد کریں"), + ("Write a message", "ایک پیغام لکھیں"), + ("Prompt", "پرامپٹ"), + ("Please wait for confirmation of UAC...", "UAC کی تصدیق کے لئے انتظار کریں..."), + ("elevated_foreground_window_tip", "الیویٹڈ_فارگراؤنڈ_ونڈو_ٹپ"), + ("Disconnected", "منقطع ہو گیا"), + ("Other", "دوسرا"), + ("Confirm before closing multiple tabs", "زیادہ ٹیبز بند کرنے سے پہلے تصدیق کریں"), + ("Keyboard Settings", "کیبورڈ ترتیبات"), + ("Full Access", "مکمل رسائی"), + ("Screen Share", "سکرین شئیر"), + ("ubuntu-21-04-required", "ubuntu-21-04 کی ضرورت"), + ("wayland-requires-higher-linux-version", "wayland کو اعلی لینکس ورژن کی ضرورت ہے"), + ("xdp-portal-unavailable", "xdp پورٹل دستیاب نہیں ہے"), + ("JumpLink", "جمپ لنک"), + ("Please Select the screen to be shared(Operate on the peer side).", "شیئر کرنے کے لیے سکرین منتخب کریں (ہم منسب کی طرف سے کام کریں)۔"), + ("Show RustDesk", "RustDesk دکھائیں"), + ("This PC", "یہ PC"), + ("or", "یا"), + ("Elevate", "علیٰ کریں"), + ("Zoom cursor", "کورسرو زوم کریں"), + ("Accept sessions via password", "پاس ورڈ کے ذریعے سیشن قبول کریں"), + ("Accept sessions via click", "کلک کے ذریعے سیشن قبول کریں"), + ("Accept sessions via both", "دونوں کے ذریعے سیشن قبول کریں"), + ("Please wait for the remote side to accept your session request...", "رضائی کے لئے انتظار کریں..."), + ("One-time Password", "ایک بارہ پاس ورڈ"), + ("Use one-time password", "ایک بارہ پاس ورڈ استعمال کریں"), + ("One-time password length", "ایک بارہ پاس ورڈ کی لمبائی"), + ("Request access to your device", "اپنے آلہ تک رسائی کا درخواست دیں"), + ("Hide connection management window", "رابطہ مینجمنٹ ونڈو چھپائیں"), + ("hide_cm_tip", "hide_cm_tip"), + ("wayland_experiment_tip", "wayland_experiment_tip"), + ("Right click to select tabs", "ٹیبز منتخب کرنے کے لیے دائیں کلک کریں"), + ("Skipped", "چھوڑا گیا"), + ("Add to address book", "پتہ کتاب میں شامل کریں"), + ("Group", "گروپ"), + ("Search", "تلاش"), + ("Closed manually by web console", "ویب کنسول کے ذریعے دستی طور پر بند کیا گیا"), + ("Local keyboard type", "مقامی کیبورڈ کا قسم"), + ("Select local keyboard type", "مقامی کیبورڈ کا قسم منتخب کریں"), + ("software_render_tip", ""), + ("Always use software rendering", "ہم sempre سافٹ ویر رینڈرنگ استعمال کریں"), + ("config_input", "config_input"), + ("config_microphone", ""), + ("request_elevation_tip", ""), + ("Wait", "انتظار کریں"), + ("Elevation Error", "علیٰ کرنے کی خرابی"), + ("Ask the remote user for authentication", "ریموٹ صارف سے تصدیق کے لیے پوچھیں"), + ("Choose this if the remote account is administrator", "ریموٹ اکاؤنٹ ایڈمنسٹریٹر ہو تو یہ منتخب کریں"), + ("Transmit the username and password of administrator", "ایڈمنسٹریٹر کا صارف نام اور پاس ورڈ پروگرام کے ذریعے بھیجیں"), + ("still_click_uac_tip", ""), + ("Request Elevation", "علیٰ کرنے کا درخواست دیں"), + ("wait_accept_uac_tip", ""), + ("Elevate successfully", "علیٰ کامیابی سے ہو گئے"), + ("uppercase", "بڑے حروف"), + ("lowercase", "چھوٹے حروف"), + ("digit", "عدد"), + ("special character", "خاص حرف"), + ("length>=8", "لمبائی>=8"), + ("Weak", "ضعیف"), + ("Medium", "درمیان"), + ("Strong", "مضبوط"), + ("Switch Sides", "پلٹنے کے سائڈس"), + ("Please confirm if you want to share your desktop?", "براہ کرم تصدیق کریں اگر آپ اپنے ڈیسک ٹاپ کو شئیر کرنا چاہتے ہیں؟"), + ("Display", "ڈسپلے"), + ("Default View Style", "ڈیفالٹ دیکھنے کا طریقہ"), + ("Default Scroll Style", "ڈیفالٹ سکرول کا طریقہ"), + ("Default Image Quality", "ڈیفالٹ تصویر کی معیار"), + ("Default Codec", "ڈیفالٹ کوڈک"), + ("Bitrate", "بٹ ریٹ"), + ("FPS", ""), + ("Auto", "خودکار"), + ("Other Default Options", "دوسروں ڈیفالٹ اختیارات"), + ("Voice call", "صوتی کال"), + ("Text chat", "متن چیٹ"), + ("Stop voice call", "صوتی کال کو روکیں"), + ("relay_hint_tip", "relay_hint_tip"), + ("Reconnect", "دوبارہ کنکٹ کریں"), + ("Codec", "کوڈک"), + ("Resolution", "ریزولیشن"), + ("No transfers in progress", "کوئی منتقلی جاری نہیں"), + ("Set one-time password length", "ایک بار کے لیے پاس ورڈ کی لمبائی سیٹ کریں"), + ("RDP Settings", "RDP سیٹنگز"), + ("Sort by", "ترتیر کے لحاظ سے"), + ("New Connection", "نئی کنکشن"), + ("Restore", "بحال کریں"), + ("Minimize", "کم کریں"), + ("Maximize", "زیادہ کریں"), + ("Your Device", "آپ کا آلہ"), + ("empty_recent_tip", "خالی حالیہ ٹپ"), + ("empty_favorite_tip", "خالی پسندیدہ ٹپ"), + ("empty_lan_tip", "خالی LAN ٹپ"), + ("empty_address_book_tip", "خالی پتہ کتاب ٹپ"), + ("Empty Username", "خالی صارف نام"), + ("Empty Password", "خالی پاس ورڈ"), + ("Me", "میں"), + ("identical_file_tip", ""), + ("show_monitors_tip", ""), + ("View Mode", "دیکھنے کا طریقہ"), + ("login_linux_tip", "login_linux_tip"), + ("verify_rustdesk_password_tip", ""), + ("remember_account_tip", ""), + ("os_account_desk_tip", ""), + ("OS Account", "OS اکاؤنٹ"), + ("another_user_login_title_tip", ""), + ("another_user_login_text_tip", ""), + ("xorg_not_found_title_tip", ""), + ("xorg_not_found_text_tip", ""), + ("no_desktop_title_tip", ""), + ("no_desktop_text_tip", ""), + ("No need to elevate", "اپنے کو ہیں نہیں"), + ("System Sound", "سسٹم سائونڈ"), + ("Default", "ڈیفالٹ"), + ("New RDP", "نیا RDP"), + ("Fingerprint", "فنگر پرنٹ"), + ("Copy Fingerprint", "فنگر پرنٹ کاپی کریں"), + ("no fingerprints", "کوئی فنگر پرنٹ نہیں"), + ("Select a peer", "ایک پیر منتخب کریں"), + ("Select peers", "پیرز منتخب کریں"), + ("Plugins", "پلگ انز"), + ("Uninstall", "ان انسٹال کریں"), + ("Update", "اپڈیٹ کریں"), + ("Enable", "فعال کریں"), + ("Disable", "غیر فعال کریں"), + ("Options", "اختیارات"), + ("resolution_original_tip", ""), + ("resolution_fit_local_tip", ""), + ("resolution_custom_tip", ""), + ("Collapse toolbar", "ٹول بار کو سکڑیں"), + ("Accept and Elevate", "قبول کریں اور علیٰ کریں"), + ("accept_and_elevate_btn_tooltip", ""), + ("clipboard_wait_response_timeout_tip", ""), + ("Incoming connection", "آنے والا کنکشن"), + ("Outgoing connection", "جانے والا کنکشن"), + ("Exit", "خارج ہوں"), + ("Open", "کھولیں"), + ("logout_tip", ""), + ("Service", "سروس"), + ("Start", "شروع کریں"), + ("Stop", "روک دیں"), + ("exceed_max_devices", ""), + ("Sync with recent sessions", "پچھلے سیشنز کے ساتھ ہم آہنگ کریں"), + ("Sort tags", "ٹیگز کو ترتیب دیں"), + ("Open connection in new tab", "کنکشن کو نئے ٹیب میں کھولیں"), + ("Move tab to new window", "ٹیب کو نئی ونڈو میں منتقل کریں"), + ("Can not be empty", "خالی نہیں ہو سکتا"), + ("Already exists", "پہلے سے موجود ہے"), + ("Change Password", "پاسورڈ تبدیل کریں"), + ("Refresh Password", "پاسورڈ ریفریش کریں"), + ("ID", ""), + ("Grid View", "گوڈ ویو"), + ("List View", "لسٹ ویو"), + ("Select", "منتخب کریں"), + ("Toggle Tags", "ٹیگز ٹوگل کریں"), + ("pull_ab_failed_tip", ""), + ("push_ab_failed_tip", ""), + ("synced_peer_readded_tip", ""), + ("Change Color", "رنگ تبدیل کریں"), + ("Primary Color", "پرائمری رنگ"), + ("HSV Color", "HSV رنگ"), + ("Installation Successful!", "انسٹالیشن کامیاب ہو گئی"), + ("Installation failed!", "انسٹالیشن ناکام ہو گئی"), + ("Reverse mouse wheel", "ریورس ماؤس وھیل"), + ("{} sessions", "{} سیشنز"), + ("scam_title", "سکم ٹائٹل"), + ("scam_text1", "سکم ٹیکسٹ 1"), + ("scam_text2", "سکم ٹیکسٹ 2"), + ("Don't show again", "دوبارہ نہ دکھائیں"), + ("I Agree", "میں قبول کرتا ہوں"), + ("Decline", "ناکام کریں"), + ("Timeout in minutes", "منٹوں میں ٹائیم آؤٹ"), + ("auto_disconnect_option_tip", ""), + ("Connection failed due to inactivity", "انفعال کی وजہ سے کنکشن ناکام ہو گیا"), + ("Check for software update on startup", "سٹارٹ اپ پر سافٹ ویر اپڈیٹ کے لیے چیک کریں"), + ("upgrade_rustdesk_server_pro_to_{}_tip", ""), + ("pull_group_failed_tip", ""), + ("Filter by intersection", "فلٹر بائی انسٹریکشن"), + ("Remove wallpaper during incoming sessions", "ان کلینگ سیشنز کے دوران والپیپر کو ہٹائیں"), + ("Test", "ٹیسٹ"), + ("display_is_plugged_out_msg", "ڈسپلے پلگڈ آؤٹ میسج"), + ("No displays", "کوئی ڈسپلے نہیں"), + ("Open in new window", "نئی ونڈو میں کھولیں"), + ("Show displays as individual windows", "ڈسپلے کو افراد کے طور پر دکھائیں"), + ("Use all my displays for the remote session", "ریموٹ سیشن کے لیے میرے تمام ڈسپلے استعمال کریں"), + ("selinux_tip", ""), + ("Change view", "ویو تبدیل کریں"), + ("Big tiles", "بڑے ٹائل"), + ("Small tiles", "چھوٹے ٹائل"), + ("List", "لسٹ"), + ("Virtual display", "ویچول دسپلے"), + ("Plug out all", "تمام پلگ آؤٹ کریں"), + ("True color (4:4:4)", "اصل رنگ (4:4:4)"), + ("Enable blocking user input", "صارف ان پٹ کو روکنے کی اجازت دیں"), + ("id_input_tip", ""), + ("privacy_mode_impl_mag_tip", ""), + ("privacy_mode_impl_virtual_display_tip", ""), + ("Enter privacy mode", "خفیہ موڈ میں داخل ہوں"), + ("Exit privacy mode", "خفیہ موڈ سے باہر نکلیں"), + ("idd_not_support_under_win10_2004_tip", ""), + ("input_source_1_tip", ""), + ("input_source_2_tip", ""), + ("Swap control-command key", "control-command کلید کو سوپ کریں"), + ("swap-left-right-mouse", "بائی-دائی ماؤس کو سوپ کریں"), + ("2FA code", "2FA کوڈ"), + ("More", "مزید"), + ("enable-2fa-title", "2FA کو فعال کریں"), + ("enable-2fa-desc", "2FA کم سے زیادہ ترتیب دینے کے لیے فعال کریں"), + ("wrong-2fa-code", "2FA کوڈ غلط ہے"), + ("enter-2fa-title", "2FA کوڈ درج کریں"), + ("Email verification code must be 6 characters.", "ای میل توثیق کوڈ 6 حروف کا ہونا چاہیے."), + ("2FA code must be 6 digits.", "2FA کوڈ 6 اعداد کا ہونا چاہیے."), + ("Multiple Windows sessions found", "متعدد ونڈوز سیشن ملے"), + ("Please select the session you want to connect to", "براہ کرم وہ سیشن منتخب کریں جس سے آپ منسلک ہونا چاہتے ہیں"), + ("powered_by_me", "میں کی طرف سے طاقتور"), + ("outgoing_only_desk_tip", ""), + ("preset_password_warning", ""), + ("Security Alert", "سیکورٹی الرٹ"), + ("My address book", "میری ایڈریس بک"), + ("Personal", "شخصی"), + ("Owner", "مالک"), + ("Set shared password", "پھیلاو پاس ورڈ مرتب کریں"), + ("Exist in", "موجود ہے"), + ("Read-only", "صرف پڑھنے کے لیے"), + ("Read/Write", "پڑھنے/لکھنے"), + ("Full Control", "پورا کنٹرول"), + ("share_warning_tip", ""), + ("Everyone", "ہر کوئی"), + ("ab_web_console_tip", ""), + ("allow-only-conn-window-open-tip", ""), + ("no_need_privacy_mode_no_physical_displays_tip", ""), + ("Follow remote cursor", "ریموٹ کرسر کی پیروی کریں"), + ("Follow remote window focus", "ریموٹ ونڈو فوکس کی پیروی کریں"), + ("default_proxy_tip", ""), + ("no_audio_input_device_tip", ""), + ("Incoming", "آنے والے"), + ("Outgoing", "بھیجے جا رہے"), + ("Clear Wayland screen selection", "Wayland سکرین کی انتخاب صاف کریں"), + ("clear_Wayland_screen_selection_tip", ""), + ("confirm_clear_Wayland_screen_selection_tip", ""), + ("android_new_voice_call_tip", ""), + ("texture_render_tip", ""), + ("Use texture rendering", "ٹیکسچر رینڈرنگ کا استعمال کریں"), + ("Floating window", "فلوٹنگ ونڈو"), + ("floating_window_tip", ""), + ("Keep screen on", "سکرین کو آن رکھیں"), + ("Never", "کبھی نہیں"), + ("During controlled", "کنٹرول کے دوران"), + ("During service is on", "سروس فعال ہو تو"), + ("Capture screen using DirectX", "DirectX کا استعمال کرکے سکرین کی تصویر لیں"), + ("Back", "واپس"), + ("Apps", "ایپس"), + ("Volume up", "آواز بڑھائیں"), + ("Volume down", "آواز کم کریں"), + ("Power", "پاور"), + ("Telegram bot", "ٹیلیگرام بات"), + ("enable-bot-tip", ""), + ("enable-bot-desc", ""), + ("cancel-2fa-confirm-tip", ""), + ("cancel-bot-confirm-tip", ""), + ("About RustDesk", "رستڈیسک کے بارے میں"), + ("Send clipboard keystrokes", "کلپ بورڈ کی چابیاں بھیجیں"), + ("network_error_tip", ""), + ("Unlock with PIN", "PIN کے ساتھ انلاک کریں"), + ("Requires at least {} characters", "کم از کم {} حروف کی ضرورت ہے"), + ("Wrong PIN", "غلط PIN"), + ("Set PIN", "PIN سیٹ کریں"), + ("Enable trusted devices", "معتبر آلے فعال کریں"), + ("Manage trusted devices", "معتبر آلے مینیج کریں"), + ("Platform", "پلیٹ فارم"), + ("Days remaining", "دن باقی"), + ("enable-trusted-devices-tip", ""), + ("Parent directory", "والد ڈائرکٹری"), + ("Resume", "جاری رکھیں"), + ("Invalid file name", "غلط فائل کا نام"), + ("one-way-file-transfer-tip", ""), + ("Authentication Required", "توثیق کی ضرورت ہے"), + ("Authenticate", "توثیق کریں"), + ("web_id_input_tip", ""), + ("Download", "ڈاؤن لوڈ کریں"), + ("Upload folder", "اپ لوڈ فولڈر"), + ("Upload files", "فائلیں اپ لوڈ کریں"), + ("Clipboard is synchronized", "کلپ بورڈ مطابق ہے"), + ("Update client clipboard", "کلپ بورڈ کو اپ ڈیٹ کریں"), + ("Untagged", "غیر تعلق یافتہ"), + ("new-version-of-{}-tip", ""), + ("Accessible devices", "قابلِ رسائی والے آلے"), + ("upgrade_remote_rustdesk_client_to_{}_tip", ""), + ("d3d_render_tip", ""), + ("Use D3D rendering", "D3D رینڈرنگ کا استعمال کریں"), + ("Printer", "پرنٹر"), + ("printer-os-requirement-tip", ""), + ("printer-requires-installed-{}-client-tip", ""), + ("printer-{}-not-installed-tip", ""), + ("printer-{}-ready-tip", ""), + ("Install {} Printer", " {} پرنٹر انسٹال کریں"), + ("Outgoing Print Jobs", "بیرونی پرنٹ کام"), + ("Incoming Print Jobs", "اندر کے پرنٹ کام"), + ("Incoming Print Job", "اندر کا پرنٹ کام"), + ("use-the-default-printer-tip", ""), + ("use-the-selected-printer-tip", ""), + ("auto-print-tip", ""), + ("print-incoming-job-confirm-tip", ""), + ("remote-printing-disallowed-tile-tip", ""), + ("remote-printing-disallowed-text-tip", ""), + ("save-settings-tip", ""), + ("dont-show-again-tip", " ٹپ دوبارہ نہ دکھائیں "), + ("Take screenshot", "اسکرین شاٹ لیں"), + ("Taking screenshot", "اسکرین شاٹ لے رہے ہیں"), + ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-action-tip", "اسکرین شاٹ ایکشن ٹپ"), + ("Save as", "حفظ کے طور پر"), + ("Copy to clipboard", "کلپ بورڈ پر کاپی کریں"), + ("Enable remote printer", "ریموٹ پرنٹر کو فعال کریں"), + ("Downloading {}", "ڈاؤن لوڈ ہو رہا ہے {}"), + ("{} Update", "{} اپ ڈیٹ"), + ("{}-to-update-tip", ""), + ("download-new-version-failed-tip", ""), + ("Auto update", "خودکار اپ ڈیٹ"), + ("update-failed-check-msi-tip", ""), + ("websocket_tip", ""), + ("Use WebSocket", "WebSocket استعمال کریں"), + ("Trackpad speed", "ٹریک پیڈ کی رفتار"), + ("Default trackpad speed", "ڈیفالٹ ٹریک پیڈ کی رفتار"), + ("Numeric one-time password", "عددی ایک مرتبہ کے لیے پاس ورڈ"), + ("Enable IPv6 P2P connection", "IPv6 P2P کنکشن کو فعال کریں"), + ("Enable UDP hole punching", "UDP ہول پنچنگ کو فعال کریں"), + ("View camera", "کیرہ دیکھیں"), + ("Enable camera", "کیرہ کو فعال کریں"), + ("No cameras", "کوئی کیرہ نہیں"), + ("view_camera_unsupported_tip", "کیرہ دیکھنے کی اجازت نہیں ہے"), + ("Terminal", "ٹرمنل"), + ("Enable terminal", "ٹرمنل کو فعال کریں"), + ("New tab", "نیا ٹیب"), + ("Keep terminal sessions on disconnect", "ڈسکنیکٹ پر ٹرمنل سیشنز کو رکھیں"), + ("Terminal (Run as administrator)", "ٹرمنل (ایڈمنسٹریٹر کے طور پر چلائیں)"), + ("terminal-admin-login-tip", "ٹرمنل ایڈمنسٹریٹر لاگ ان تیپ"), + ("Failed to get user token.", "صارف ٹوکن حاصل کرنے میں ناکام"), + ("Incorrect username or password.", "غلط صارف نام یا پاس ورڈ"), + ("The user is not an administrator.", "صارف ایڈمنسٹریٹر نہیں ہے"), + ("Failed to check if the user is an administrator.", "صارف ایڈمنسٹریٹر ہے یا نہیں چیک کرنے میں ناکام"), + ("Supported only in the installed version.", "صرف انسٹال شدہ ورژن میں معاونت کی جاتی ہے۔"), + ("elevation_username_tip", ""), + ("Preparing for installation ...", "انسٹالیشن کی تیاری ..."), + ("Show my cursor", "میرا کرسر دکھائیں"), + ("Scale custom", "اپنی مرضی کے مطابق پیمانہ"), + ("Custom scale slider", "اپنی مرضی کے مطابق پیمانہ سلائیڈر"), + ("Decrease", "کم کریں"), + ("Increase", "زیادہ کریں"), + ("Show virtual mouse", "ورچوئل ماؤس دکھائیں"), + ("Virtual mouse size", "ورچوئل ماؤس کا سائز"), + ("Small", "چھوٹا"), + ("Large", "بڑا"), + ("Show virtual joystick", "ورچوئل جوائس اسٹک دکھائیں"), + ("Edit note", "نوٹ میں ترمیم کریں"), + ("Alias", "عرف نام"), + ("ScrollEdge", "اسکرول ایج"), + ("Allow insecure TLS fallback", "غیر محفوظ TLS فالبیک کی اجازت دیں"), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable UDP", "UDP کو غیر فعال کریں"), + ("disable-udp-tip", ""), + ("server-oss-not-support-tip", ""), + ("input note here", "نوٹ یہاں درج کریں"), + ("note-at-conn-end-tip", ""), + ("Show terminal extra keys", "ٹرمنل اضافی کیز دکھائیں"), + ("Relative mouse mode", "رشتہ دار ماؤس موڈ"), + ("rel-mouse-not-supported-peer-tip", ""), + ("rel-mouse-not-ready-tip", ""), + ("rel-mouse-lock-failed-tip", ""), + ("rel-mouse-exit-{}-tip", ""), + ("rel-mouse-permission-lost-tip", ""), + ("Changelog", "تبدیلی کا لاگ"), + ("keep-awake-during-outgoing-sessions-label", ""), + ("keep-awake-during-incoming-sessions-label", ""), + ("Continue with {}", "continue-with-{}"), + ("Display Name", "display-name"), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ].iter().cloned().collect(); +} + diff --git a/src/lang/vi.rs b/src/lang/vi.rs index af358831e..8995ade87 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"), @@ -465,17 +464,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("identical_file_tip", "Tệp này giống hệt ở phía đối tác."), ("show_monitors_tip", "Hiện màn hình trên thanh công cụ"), ("View Mode", "Chế độ xem"), - ("login_linux_tip", "Cần đăng nhập tài khoản Linux để kích hoạt X session."), ("verify_rustdesk_password_tip", "Xác thực mật khẩu RustDesk"), - ("remember_account_tip", "Nhớ tài khoản này"), - ("os_account_desk_tip", "Tài khoản OS được dùng để đăng nhập và chạy session không màn hình (headless)."), - ("OS Account", "Tài khoản OS"), - ("another_user_login_title_tip", "Người dùng khác đã đăng nhập"), - ("another_user_login_text_tip", "Ngắt kết nối hiện tại"), - ("xorg_not_found_title_tip", "Không tìm thấy Xorg"), - ("xorg_not_found_text_tip", "Vui lòng cài đặt Xorg"), - ("no_desktop_title_tip", "Không có desktop"), - ("no_desktop_text_tip", "Vui lòng cài đặt GNOME hoặc desktop khác."), ("No need to elevate", "Không cần nâng quyền"), ("System Sound", "Âm thanh hệ thống"), ("Default", "Mặc định"), @@ -483,14 +472,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "Dấu vân tay"), ("Copy Fingerprint", "Sao chép fingerprint"), ("no fingerprints", "không có fingerprint"), - ("Select a peer", "Chọn một đối tác"), - ("Select peers", "Chọn các đối tác"), - ("Plugins", "Plugin"), - ("Uninstall", "Gỡ cài đặt"), ("Update", "Cập nhật"), - ("Enable", "Bật"), - ("Disable", "Tắt"), - ("Options", "Tùy chọn"), ("resolution_original_tip", "Độ phân giải gốc"), ("resolution_fit_local_tip", "Vừa với máy cục bộ"), ("resolution_custom_tip", "Độ phân giải tùy chỉnh"), @@ -677,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Không hỗ trợ chụp gộp nhiều màn hình."), ("screenshot-action-tip", "Hành động chụp màn hình"), ("Save as", "Lưu thành"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Sao chép vào Clipboard"), ("Enable remote printer", "Bật máy in từ xa"), ("Downloading {}", "Đang tải xuống {}"), @@ -773,7 +758,10 @@ 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.", ""), + ("Continue", "Tiếp tục"), + ("Browser didn't open? Use the url below to sign in.", "Trình duyệt không mở được? Hãy dùng URL bên dưới để đăng nhập."), + ("Lock canvas", "Khóa khung hình"), + ("Sync clipboard between sessions", "Đồng bộ clipboard giữa các phiên"), + ("sync-clipboard-between-sessions-tip", "Văn bản hoặc hình ảnh được sao chép trong một phiên từ xa cũng được gửi đến clipboard của các phiên đã kết nối khác."), ].iter().cloned().collect(); } diff --git a/src/lib.rs b/src/lib.rs index 49cb2b7e9..20d5d6aab 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/platform/linux.rs b/src/platform/linux.rs index ab6b1879b..099d00a2d 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,11 +1,20 @@ 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, bail, - config::{keys::OPTION_ALLOW_LINUX_HEADLESS, Config}, + config::Config, libc::{c_char, c_int, c_long, c_uint, c_ulong, c_void}, log, message_proto::{DisplayInfo, Resolution}, @@ -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! { - pub static ref IS_X11: bool = hbb_common::platform::linux::is_x11_or_headless(); + /// 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! { + /// `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 = { @@ -58,6 +96,9 @@ lazy_static::lazy_static! { }; static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + static ref GNOME_MONITOR_LAYOUT_MODE_CACHE: std::sync::Mutex< + Option<(Instant, Option)>, + > = Default::default(); // https://github.com/rustdesk/rustdesk/issues/13705 // Check if `sudo -E` actually preserves environment. // @@ -90,6 +131,141 @@ lazy_static::lazy_static! { }; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GnomeMonitorLayoutMode { + Logical, + Physical, +} + +impl GnomeMonitorLayoutMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Logical => "logical", + Self::Physical => "physical", + } + } +} + +fn gnome_monitor_layout_mode_from_value(value: u32) -> Option { + // Upstream: https://gitlab.gnome.org/GNOME/mutter/-/blob/main/data/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml + // Ubuntu mode 3: https://git.launchpad.net/ubuntu/+source/mutter/tree/debian/patches/x11-Add-support-for-fractional-scaling-using-Randr.patch + match value { + 1 | 3 => Some(GnomeMonitorLayoutMode::Logical), + 2 => Some(GnomeMonitorLayoutMode::Physical), + _ => None, + } +} + +pub fn gnome_monitor_layout_mode() -> Option { + if let Ok(cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + if let Some((updated_at, result)) = *cache { + if updated_at.elapsed() < Duration::from_secs(10) { + return result; + } + } + } + + let result = (|| { + let is_gnome_desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .any(|desktop| { + desktop.eq_ignore_ascii_case("gnome") || desktop.eq_ignore_ascii_case("unity") + }); + let is_gnome_session = std::env::var("DESKTOP_SESSION") + .unwrap_or_default() + .to_ascii_lowercase(); + if !is_gnome_desktop && !is_gnome_session.contains("gnome") { + return None; + } + use dbus::{arg::PropMap, blocking::BlockingSender}; + + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::warn!("Failed to connect to the session bus for GNOME monitor layout: {err}"); + return None; + } + }; + let message = match dbus::Message::new_method_call( + "org.gnome.Mutter.DisplayConfig", + "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", + "GetCurrentState", + ) { + Ok(message) => message, + Err(err) => { + log::warn!("Failed to create GNOME monitor layout query: {err}"); + return None; + } + }; + let reply = match conn.send_with_reply_and_block(message, Duration::from_secs(2)) { + Ok(reply) => reply, + Err(err) => { + log::warn!("Failed to query GNOME monitor layout: {err}"); + return None; + } + }; + let mut args = reply.iter_init(); + for _ in 0..3 { + if !args.next() { + log::warn!("GNOME monitor layout reply is missing properties"); + return None; + } + } + let properties: PropMap = match args.read() { + Ok(properties) => properties, + Err(err) => { + log::warn!("Failed to read GNOME monitor layout properties: {err}"); + return None; + } + }; + let Some(value) = dbus::arg::prop_cast::(&properties, "layout-mode").copied() else { + log::warn!("GNOME monitor layout reply has no layout-mode"); + return None; + }; + let mode = gnome_monitor_layout_mode_from_value(value); + if mode.is_none() { + log::warn!("GNOME monitor layout reply has unknown layout-mode {value}"); + } + mode + })(); + if let Ok(mut cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + *cache = Some((Instant::now(), result)); + } + result +} + +#[cfg(test)] +mod gnome_monitor_layout_tests { + use super::*; + + #[test] + fn maps_logical_layouts() { + assert_eq!( + gnome_monitor_layout_mode_from_value(1), + Some(GnomeMonitorLayoutMode::Logical) + ); + assert_eq!( + gnome_monitor_layout_mode_from_value(3), + Some(GnomeMonitorLayoutMode::Logical) + ); + } + + #[test] + fn maps_physical_layout() { + assert_eq!( + gnome_monitor_layout_mode_from_value(2), + Some(GnomeMonitorLayoutMode::Physical) + ); + } + + #[test] + fn rejects_unknown_layout() { + assert_eq!(gnome_monitor_layout_mode_from_value(4), None); + } +} + #[inline] fn update_active_user_lookup_cache(desktop: &Desktop) { if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() { @@ -197,17 +373,40 @@ pub struct xcb_xfixes_get_cursor_image { pub pixels: *const c_long, } -#[inline] -pub fn is_headless_allowed() -> bool { - Config::get_option(OPTION_ALLOW_LINUX_HEADLESS) == "Y" -} - #[inline] pub fn is_login_screen_wayland() -> bool { let values = get_values_of_seat0_with_gdm_wayland(&[0, 2]); is_gdm_user(&values[1]) && get_display_server_of_session(&values[0]) == DISPLAY_SERVER_WAYLAND } +/// An explicit `RUSTDESK_FORCED_DISPLAY_SERVER` is an operator override, and the root service +/// forwards it to the per-user server on purpose: the greeter correction may only fix an +/// AUTO-detected answer, never argue with the operator — a half-applied override would leave +/// `get_display_server()` and the DRM routing gates disagreeing with each other. +#[cfg(feature = "drm")] +pub(crate) fn display_server_forced() -> bool { + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER").is_ok() +} + +/// X11 as far as the DRM path is concerned: a Wayland greeter is not, unless the operator +/// forced the display server. +/// +/// Both halves unmemoised, for the retry loops that must keep asking until seat0 can be named. +#[cfg(feature = "drm")] +pub fn is_x11_for_drm() -> bool { + scrap::is_x11() && (display_server_forced() || !is_login_screen_wayland()) +} + +/// Memoised `is_login_screen_wayland`, for per-frame callers that must not run `loginctl`. +/// +/// Only from the per-session `--server`: it is spawned after the session is identified, so the +/// answer is settled. Anything that can run mid-boot must use the uncached form. +#[cfg(feature = "drm")] +#[inline] +pub fn is_login_screen_wayland_cached() -> bool { + *IS_LOGIN_SCREEN_WAYLAND +} + #[inline] fn sleep_millis(millis: u64) { std::thread::sleep(Duration::from_millis(millis)); @@ -361,6 +560,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 +602,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 +929,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()); @@ -713,18 +996,6 @@ fn stop_rustdesk_servers() { )); } -#[inline] -fn stop_subprocess() { - let _ = run_cmds(&format!( - r##"ps -ef | grep '/etc/{}/xorg.conf' | grep -v grep | awk '{{print $2}}' | xargs -r kill -9"##, - crate::get_app_name().to_lowercase(), - )); - let _ = run_cmds(&format!( - r##"ps -ef | grep -E '{} +--cm-no-ui' | grep -v grep | awk '{{print $2}}' | xargs -r kill -9"##, - crate::get_app_name().to_lowercase(), - )); -} - fn should_start_server( try_x11: bool, is_display_changed: bool, @@ -738,13 +1009,7 @@ fn should_start_server( let mut start_new = false; let mut should_kill = false; - if desktop.is_headless() { - if !uid.is_empty() { - // From having a monitor to not having a monitor. - *uid = "".to_owned(); - should_kill = true; - } - } else if is_display_changed || desktop.uid != *uid && !desktop.uid.is_empty() { + if is_display_changed || desktop.uid != *uid && !desktop.uid.is_empty() { *uid = desktop.uid.clone(); if try_x11 { set_x11_env(&desktop); @@ -803,13 +1068,35 @@ fn force_stop_server() { pub fn start_os_service() { check_if_stop_service(); stop_rustdesk_servers(); - stop_subprocess(); start_uinput_service(); std::thread::spawn(|| { 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()); @@ -830,8 +1117,7 @@ pub fn start_os_service() { desktop.refresh(); update_active_user_lookup_cache(&desktop); - // Duplicate logic here with should_start_server - // Login wayland will try to start a headless --server. + // Duplicate logic here with should_start_server. if desktop.username == "root" || desktop.is_login_wayland() { // try kill subprocess "--server" stop_server(&mut user_server); @@ -846,9 +1132,39 @@ pub fn start_os_service() { &mut last_restart, &mut server, ) { - 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" @@ -868,7 +1184,6 @@ pub fn start_os_service() { &mut last_restart, &mut user_server, ) { - stop_subprocess(); force_stop_server(); start_server(Some(&desktop), &mut user_server); } @@ -878,17 +1193,14 @@ pub fn start_os_service() { stop_server(&mut server); } - let keeps_headless = sid.is_empty() && desktop.is_headless(); let keeps_session = sid == desktop.sid; - if keeps_headless || keeps_session { + if keeps_session { // for fixing https://github.com/rustdesk/rustdesk/issues/3129 to avoid too much dbus calling, sleep_millis(500); } else { sleep_millis(super::SERVICE_INTERVAL); } - if !desktop.is_headless() { - sid = desktop.sid.clone(); - } + sid = desktop.sid.clone(); } if let Some(ps) = user_server.take().as_mut() { @@ -924,24 +1236,33 @@ pub fn get_active_userid() -> String { #[inline] /// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. pub fn get_active_userid_fresh() -> String { + // A Wayland greeter owns seat0 while it is up and the DRM backend serves it, so a uid gate that + // cannot see it rejects the greeter's own `--server`. `Desktop::refresh` reads it the same way. + #[cfg(feature = "drm")] + return get_values_of_seat0_with_gdm_wayland(&[1])[0].clone(); + #[cfg(not(feature = "drm"))] get_values_of_seat0(&[1])[0].clone() } +#[inline] +/// The cached active uid as a number, or `None` when the cache is empty. Unlike `get_active_userid` +/// this NEVER falls back to a blocking `loginctl` seat0 lookup, so it is safe to call on an async +/// runtime thread and on a hot path (e.g. per-frame re-auth): a cache miss returns `None` for the +/// caller to treat as "active session momentarily unknown" rather than stalling on a subprocess. +pub fn get_active_userid_cached() -> Option { + get_active_user_id_name_from_cache().and_then(|(uid, _)| uid.parse::().ok()) +} + fn get_cm() -> bool { - // We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems. - if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() { - for line in String::from_utf8_lossy(&output.stdout).lines() { - if line.contains(&format!( - "{} --cm", - std::env::current_exe() - .unwrap_or("".into()) - .to_string_lossy() - )) { - return true; - } - } - } - false + // Runs twice a second in the service loop, so walk /proc rather than forking `ps aux`; that + // fork is also what the `CMD_PS` audit-message workaround this replaces was for. + let cm = format!( + "{} --cm", + std::env::current_exe() + .unwrap_or_default() + .to_string_lossy() + ); + any_process(None, "cmdline", |cmdline| cmdline.contains(&cm)) } pub fn is_login_wayland() -> bool { @@ -1031,7 +1352,6 @@ fn is_flatpak() -> bool { std::path::PathBuf::from("/.flatpak-info").exists() } -// Headless is enabled, always return true. pub fn is_prelogin() -> bool { if is_flatpak() { return false; @@ -1251,6 +1571,34 @@ fn get_envs<'a>( process_pat: &str, names: &[&'a str], ) -> std::collections::HashMap<&'a str, String> { + get_envs_where(uid, process_pat, names, false, |count| count == names.len()) +} + +/// The newest process matching `process_pat`, whatever it happens to carry: the semantics of the +/// `ps -u -f | grep | tail -1` pipeline the callers below used before. A variable this +/// process does not have means moving on to the next pattern, never on to an older process that +/// may belong to a session which has since logged out. +fn get_envs_of_newest<'a>( + uid: &str, + process_pat: &str, + names: &[&'a str], +) -> std::collections::HashMap<&'a str, String> { + get_envs_where(uid, process_pat, names, true, |_| true) +} + +/// `get_envs` with the caller's own process order and its own notion of a complete answer, told +/// how many of `names` the process carries: the first process `accept` takes wins outright, and +/// the count-based ranking is only the fallback for when no process is accepted at all. +fn get_envs_where<'a, F>( + uid: &str, + process_pat: &str, + names: &[&'a str], + newest_first: bool, + mut accept: F, +) -> std::collections::HashMap<&'a str, String> +where + F: FnMut(usize) -> bool, +{ // The tie-breaking logic uses a u64 bitmask, limiting us to 64 variables. debug_assert!( names.len() <= 64, @@ -1277,21 +1625,24 @@ fn get_envs<'a>( let mut best_count = 0usize; let mut best_mask: u64 = 0; - // Iterate /proc to find matching processes + // Iterate /proc to find matching processes. `newest_first` is only for `get_envs_of_newest`, + // whose callers need the last PID-ordered match their `ps ... | tail -1` pipelines took; + // without it the order is whatever readdir returns, which is what `get_envs` has always used. + // Neither order identifies the active session -- a user with two live graphical sessions has + // one of each, and picking by PID guesses. See `Desktop::refresh` for who owns that question. let Ok(entries) = std::fs::read_dir("/proc") else { return best; }; + let mut pids: Vec = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str()?.parse::().ok()) + .collect(); + if newest_first { + pids.sort_unstable_by(|a, b| b.cmp(a)); + } - for entry in entries.flatten() { - let file_name = entry.file_name(); - let Some(pid_str) = file_name.to_str() else { - continue; - }; - if !pid_str.chars().all(|c| c.is_ascii_digit()) { - continue; - } - - let proc_path = entry.path(); + for pid in pids { + let proc_path = std::path::Path::new("/proc").join(pid.to_string()); // Check if process belongs to the specified uid if let Ok(meta) = std::fs::metadata(&proc_path) { @@ -1309,15 +1660,18 @@ fn get_envs<'a>( continue; }; let cmdline_str = String::from_utf8_lossy(&cmdline).replace('\0', " "); - if !re.is_match(&cmdline_str) { + // The `grep -v 'grep'` of the pipeline this replaces. A user grepping for one of these + // patterns is otherwise the newest match for it, and answers with whatever environment + // their shell had -- an X forwarding endpoint over ssh, say. + if cmdline_str.contains("grep") || !re.is_match(&cmdline_str) { continue; } - // Read environ and extract matching variables - let environ_path = proc_path.join("environ"); - let Ok(environ) = std::fs::read(&environ_path) else { - continue; - }; + // Read environ and extract matching variables. A read that fails -- the process exited + // between these two reads -- is a process carrying none of `names`, not a process to + // skip: skipping it would hand `newest_first` on to an older PID, where the pipeline + // this replaces stopped at the single PID its `tail -1` had already picked. + let environ = std::fs::read(proc_path.join("environ")).unwrap_or_default(); let mut found = empty.clone(); let mut found_count = 0usize; @@ -1348,14 +1702,14 @@ fn get_envs<'a>( found_mask |= bit; } } - - if found_count == names.len() { - return found; - } } } } + if accept(found_count) { + return found; + } + if found_count > best_count || (found_count == best_count && found_mask > best_mask) { best = found; best_count = found_count; @@ -1366,29 +1720,37 @@ fn get_envs<'a>( best } -/// Deprecated: Use `get_envs` instead. -/// -/// https://github.com/rustdesk/rustdesk/discussions/11959 -/// -/// **Note**: This function is retained for conservative migration. The plan is to gradually -/// transition all callers to `get_envs` after it proves stable and reliable. Once `get_envs` -/// is confirmed to work correctly across all use cases, this function will be removed entirely. -/// -/// # Arguments -/// * `name` - Environment variable name to retrieve -/// * `uid` - User ID to filter processes -/// * `process` - Process name pattern to match -/// -/// # Returns -/// The environment variable value, or empty string if not found -#[inline] -fn get_env(name: &str, uid: &str, process: &str) -> String { - let cmd = format!("ps -u {} -f | grep -E '{}' | grep -v 'grep' | tail -1 | awk '{{print $2}}' | xargs -I__ cat /proc/__/environ 2>/dev/null | tr '\\0' '\\n' | grep '^{}=' | tail -1 | sed 's/{}=//g'", uid, process, name, name); - if let Ok(x) = run_cmds(&cmd) { - x.trim_end().to_string() - } else { - "".to_owned() +/// True when `pred` accepts the `/proc//` of any process, NULs turned into spaces, +/// optionally only of processes owned by `uid`. +/// Reads `/proc` directly instead of forking `ps` / `pgrep`, for the service-loop callers below. +fn any_process bool>(uid: Option, file: &str, pred: F) -> bool { + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(pid_str) = file_name.to_str() else { + continue; + }; + if !pid_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let proc_path = entry.path(); + if let Some(uid) = uid { + use std::os::unix::fs::MetadataExt; + match std::fs::metadata(&proc_path) { + Ok(meta) if meta.uid() == uid => {} + _ => continue, + } + } + let Ok(content) = std::fs::read(proc_path.join(file)) else { + continue; + }; + if pred(&String::from_utf8_lossy(&content).replace('\0', " ")) { + return true; + } } + false } #[inline] @@ -1606,12 +1968,16 @@ pub fn change_resolution_directly(name: &str, width: usize, height: usize) -> Re Ok(()) } +/// Scoped to `uid`, the user of the session being refreshed: the compositor starts Xwayland as +/// that user, so another user's Xwayland -- a switched-away session, a second seat -- answering +/// this used to route a pure-Wayland session into the Xwayland probe, which has no display for +/// it to find. A uid that cannot be parsed falls back to the unscoped answer. #[inline] -pub fn is_xwayland_running() -> bool { - if let Ok(output) = run_cmds("pgrep -a Xwayland") { - return output.contains("Xwayland"); - } - false +pub fn is_xwayland_running(uid: &str) -> bool { + // Same test as the `pgrep -a Xwayland` this replaces: the process name, not its command line. + any_process(uid.parse::().ok(), "comm", |comm| { + comm.contains("Xwayland") + }) } mod desktop { @@ -1632,6 +1998,18 @@ mod desktop { const ENV_KEY_WAYLAND_DISPLAY: &str = "WAYLAND_DISPLAY"; const ENV_KEY_DBUS_SESSION_BUS_ADDRESS: &str = "DBUS_SESSION_BUS_ADDRESS"; + /// A compositor that runs Xwayland without exporting `XAUTHORITY` (wlroots, e.g. Hyprland) + /// still hands out a usable session through the Wayland side. Requiring xauth there never + /// succeeded, so every refresh ran the retry loop to the end. + /// https://github.com/rustdesk/rustdesk/issues/15952 + fn is_session_env_complete(envs: &std::collections::HashMap<&str, String>) -> bool { + let value = |key: &str| envs.get(key).map_or("", |v| v.as_str()); + !value(ENV_KEY_DISPLAY).is_empty() + && (!value(ENV_KEY_XAUTHORITY).is_empty() + || (!value(ENV_KEY_WAYLAND_DISPLAY).is_empty() + && !value(ENV_KEY_DBUS_SESSION_BUS_ADDRESS).is_empty())) + } + #[derive(Debug, Clone, Default)] pub struct Desktop { pub sid: String, @@ -1642,7 +2020,6 @@ mod desktop { pub xauth: String, pub home: String, pub dbus: String, - pub is_rustdesk_subprocess: bool, pub wl_display: String, } @@ -1657,11 +2034,6 @@ mod desktop { super::is_gdm_user(&self.username) && self.protocol == DISPLAY_SERVER_WAYLAND } - #[inline] - pub fn is_headless(&self) -> bool { - self.sid.is_empty() || self.is_rustdesk_subprocess - } - fn get_display_xauth_wayland(&mut self) { for _ in 1..=10 { // Prefer Wayland-related variables first when multiple portal processes match. @@ -1704,15 +2076,67 @@ mod desktop { PLASMA_KDED, tray.as_str(), ]; + self.display.clear(); + self.xauth.clear(); + self.wl_display.clear(); + self.dbus.clear(); + let mut kept = 0u8; for proc in display_proc { - self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); - self.xauth = get_env(ENV_KEY_XAUTHORITY, &self.uid, proc); - self.wl_display = get_env(ENV_KEY_WAYLAND_DISPLAY, &self.uid, proc); - self.dbus = get_env(ENV_KEY_DBUS_SESSION_BUS_ADDRESS, &self.uid, proc); - if !self.display.is_empty() && !self.xauth.is_empty() { + let mut envs = get_envs_of_newest( + &self.uid, + proc, + &[ + ENV_KEY_DISPLAY, + ENV_KEY_XAUTHORITY, + ENV_KEY_WAYLAND_DISPLAY, + ENV_KEY_DBUS_SESSION_BUS_ADDRESS, + ], + ); + let complete = is_session_env_complete(&envs); + let display = envs.remove(ENV_KEY_DISPLAY).unwrap_or_default(); + let xauth = envs.remove(ENV_KEY_XAUTHORITY).unwrap_or_default(); + let wl_display = envs.remove(ENV_KEY_WAYLAND_DISPLAY).unwrap_or_default(); + let dbus = envs + .remove(ENV_KEY_DBUS_SESSION_BUS_ADDRESS) + .unwrap_or_default(); + // Take a candidate whole. Two graphical sessions of one user each answer + // some of these, and a display paired with another session's xauth or + // compositor is a pair that never existed. So rank candidates rather than + // merge them, and keep the best seen: the later patterns are fallbacks. + // + // The Wayland-only rank matters when `is_xwayland_running` matched some other + // user's Xwayland and this session has none of its own. Nothing here can then + // answer with a display, and dropping the candidate for that would leave the + // child server without the compositor and bus of a session that is perfectly + // serveable through them. + let rank = if complete { + 3 + } else if !wl_display.is_empty() && !dbus.is_empty() { + 2 + } else if !display.is_empty() { + 1 + } else { + 0 + }; + if rank > kept { + kept = rank; + self.display = display; + self.xauth = xauth; + self.wl_display = wl_display; + self.dbus = dbus; + } + if complete { return; } } + // The Wayland pair on its own is a session the child server can be started + // against -- it is what `get_display_xauth_wayland` returns on. Retrying is for a + // session that has not finished coming up, and a compositor whose Xwayland starts + // on demand may never export a `DISPLAY` for this walk to find, so waiting ten + // more rounds for one costs the whole probe again on every refresh. + if kept >= 2 { + break; + } sleep_millis(300); } } @@ -1728,7 +2152,9 @@ mod desktop { SDDM_GREETER, ]; for proc in display_proc { - self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); + self.display = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_DISPLAY]) + .remove(ENV_KEY_DISPLAY) + .unwrap_or_default(); if !self.display.is_empty() { break; } @@ -1739,6 +2165,21 @@ mod desktop { sleep_millis(300); } + if self.display.is_empty() { + // logind stores the value pam_systemd was handed at session creation, which is not + // necessarily a local display: it can be qualified with this host (`myhost:0`) or + // name an X forwarding endpoint (`localhost:10.0`), and some setups record a bare + // `:`. Strip this host, then require a display number. `localhost` is deliberately + // left in place: a non-empty display here suppresses every fallback below, both + // `get_display_by_user` and the `:0` default, so anything not local must not pass. + let display = Self::get_display_from_session(&self.sid) + .replace(&hbb_common::whoami::hostname(), ""); + if display.strip_prefix(':').map_or(false, |number| { + number.starts_with(|c: char| c.is_ascii_digit()) + }) { + self.display = display; + } + } if self.display.is_empty() { self.display = Self::get_display_by_user(&self.username); } @@ -1751,6 +2192,34 @@ mod desktop { .replace("localhost", ""); } + fn get_display_from_session(session: &str) -> String { + if session.is_empty() { + return String::new(); + } + + match Command::new(CMD_LOGINCTL.as_str()) + .args(["show-session", "-p", "Display", session]) + .output() + { + Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) + .trim() + .strip_prefix("Display=") + .unwrap_or_default() + .to_owned(), + Ok(output) => { + log::debug!( + "Failed to get display for session {session}: {}", + output.status + ); + String::new() + } + Err(err) => { + log::debug!("Failed to get display for session {session}: {err}"); + String::new() + } + } + } + fn get_home(&mut self) { self.home = "".to_string(); @@ -1816,7 +2285,9 @@ mod desktop { tray.as_str(), ]; for proc in display_proc { - self.xauth = get_env("XAUTHORITY", &self.uid, proc); + self.xauth = get_envs_of_newest(&self.uid, proc, &[ENV_KEY_XAUTHORITY]) + .remove(ENV_KEY_XAUTHORITY) + .unwrap_or_default(); if !self.xauth.is_empty() { break; } @@ -1899,25 +2370,11 @@ mod desktop { last } - fn set_is_subprocess(&mut self) { - self.is_rustdesk_subprocess = false; - let cmd = format!( - "ps -ef | grep '{}/xorg.conf' | grep -v grep | wc -l", - crate::get_app_name().to_lowercase() - ); - if let Ok(res) = run_cmds(&cmd) { - if res.trim() != "0" { - self.is_rustdesk_subprocess = true; - } - } - } - pub fn refresh(&mut self) { if !self.sid.is_empty() && is_active_and_seat0(&self.sid) { // Xwayland display and xauth may not be available in a short time after login. - if is_xwayland_running() && !self.is_login_wayland() { + if is_xwayland_running(&self.uid) && !self.is_login_wayland() { self.get_display_xauth_xwayland(); - self.is_rustdesk_subprocess = false; } else if self.is_wayland() { self.get_display_xauth_wayland(); } @@ -1927,7 +2384,6 @@ mod desktop { let seat0_values = get_values_of_seat0_with_gdm_wayland(&[0, 1, 2]); if seat0_values[0].is_empty() { *self = Self::default(); - self.is_rustdesk_subprocess = false; return; } @@ -1938,22 +2394,35 @@ mod desktop { if self.is_login_wayland() { 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; } self.get_home(); if self.is_wayland() { - if is_xwayland_running() { + if is_xwayland_running(&self.uid) { self.get_display_xauth_xwayland(); } else { self.get_display_xauth_wayland(); } - self.is_rustdesk_subprocess = false; } else { self.get_display_x11(); self.get_xauth_x11(); - self.set_is_subprocess(); } } } @@ -1982,18 +2451,167 @@ mod desktop { } } -pub struct WakeLock(Option); +/// A session-bus idle-inhibit interface, tried in order; the first that answers wins. +/// `org.freedesktop.ScreenSaver` is absent on purpose: that is the one `keepawake` already tried. +struct SessionInhibitTarget { + dest: &'static str, + path: &'static str, + iface: &'static str, + /// GNOME takes `(app_id, xid, reason, flags)`; PowerManagement takes `(app, reason)`. + gnome_shape: bool, + uninhibit: &'static str, +} + +/// `org.gnome.SessionManager.Inhibit` flag 8 = idle only; logout/switch-user/suspend would take +/// away actions the person at the machine should keep. +const GNOME_INHIBIT_IDLE: u32 = 8; + +const SESSION_INHIBIT_TARGETS: &[SessionInhibitTarget] = &[ + // Measured on a GDM greeter: output held 129.9 s with the inhibit, 30.3 s without. + SessionInhibitTarget { + dest: "org.gnome.SessionManager", + path: "/org/gnome/SessionManager", + iface: "org.gnome.SessionManager", + gnome_shape: true, + uninhibit: "Uninhibit", + }, + // What powerdevil and xfce4-power-manager implement. NOT tested here; costs one failed call + // where absent, and the log below names every interface tried. + SessionInhibitTarget { + dest: "org.freedesktop.PowerManagement", + path: "/org/freedesktop/PowerManagement/Inhibit", + iface: "org.freedesktop.PowerManagement.Inhibit", + gnome_shape: false, + uninhibit: "UnInhibit", + }, +]; + +/// Idle inhibit for the case `keepawake` cannot serve: it inhibits `org.freedesktop.ScreenSaver`, +/// which a GDM greeter bus neither provides nor can activate, so its `create()` fails outright. +/// The connection is kept because the inhibit is bound to it: dropping it releases the inhibit. +struct SessionIdleInhibit { + conn: dbus::blocking::Connection, + target: &'static SessionInhibitTarget, + cookie: u32, +} + +impl SessionIdleInhibit { + fn new(reason: &str) -> Option { + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::info!("wakelock: no session bus for the idle inhibit fallback ({err})"); + return None; + } + }; + let app = crate::get_app_name(); + let mut refused = Vec::new(); + for target in SESSION_INHIBIT_TARGETS { + let res: Result<(u32,), dbus::Error> = { + let proxy = conn.with_proxy( + target.dest, + target.path, + std::time::Duration::from_secs(3), + ); + if target.gnome_shape { + // Inhibit(s app_id, u xid, s reason, u flags) -> u cookie; xid 0 = no window. + proxy.method_call( + target.iface, + "Inhibit", + (app.clone(), 0u32, reason.to_owned(), GNOME_INHIBIT_IDLE), + ) + } else { + proxy.method_call(target.iface, "Inhibit", (app.clone(), reason.to_owned())) + } + }; + match res { + Ok((cookie,)) => { + log::info!( + "wakelock: holding a {} idle inhibit (cookie {cookie})", + target.dest + ); + return Some(Self { + conn, + target, + cookie, + }); + } + Err(err) => refused.push(format!("{}: {err}", target.dest)), + } + } + // Name every interface tried and why it failed: on an untested desktop this log is what + // turns "the screen still blanks" into a report naming the missing interface. + log::info!( + "wakelock: no session idle inhibitor answered, so the compositor may still blank this \ + screen ({})", + refused.join("; ") + ); + None + } +} + +impl Drop for SessionIdleInhibit { + fn drop(&mut self) { + let proxy = self.conn.with_proxy( + self.target.dest, + self.target.path, + std::time::Duration::from_secs(3), + ); + // Best effort: the session manager ties the inhibit to the caller's bus name, so dropping + // `conn` below releases it even if this call does not get through. + let res: Result<(), dbus::Error> = + proxy.method_call(self.target.iface, self.target.uninhibit, (self.cookie,)); + if let Err(err) = res { + log::debug!("wakelock: releasing the idle inhibit by closing the bus instead ({err})"); + } + } +} + +pub struct WakeLock(Option, Option); impl WakeLock { pub fn new(display: bool, idle: bool, sleep: bool) -> Self { - WakeLock( - keepawake::Builder::new() - .display(display) - .idle(idle) - .sleep(sleep) - .create() - .ok(), - ) + match keepawake::Builder::new() + .display(display) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => WakeLock(Some(handle), None), + Err(err) => { + // Not `.ok()`: a discarded error is how a login screen ran with no inhibitor at + // all and nobody noticed. + log::info!("wakelock: keepawake could not take the inhibit ({err})"); + // keepawake asks for the ScreenSaver inhibit first and abandons the whole request + // if it fails, losing the logind idle/sleep inhibits that stop the HOST suspending + // mid-session. Re-ask without the display part: those are on the system bus. + let system = if idle || sleep { + match keepawake::Builder::new() + .display(false) + .idle(idle) + .sleep(sleep) + .create() + { + Ok(handle) => Some(handle), + Err(err) => { + log::info!( + "wakelock: the logind idle/sleep inhibit did not come back \ + either ({err})" + ); + None + } + } + } else { + None + }; + let session = if display { + SessionIdleInhibit::new("incoming session") + } else { + None + }; + WakeLock(system, session) + } + } } } diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs deleted file mode 100644 index 4cfde61a2..000000000 --- a/src/platform/linux_desktop_manager.rs +++ /dev/null @@ -1,1171 +0,0 @@ -use super::{linux::*, ResultType}; -use crate::client::{ - LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, - LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, - LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG, -}; -use hbb_common::{ - allow_err, bail, log, - rand::prelude::*, - tokio::time, - users::{get_user_by_name, os::unix::UserExt, User}, -}; -use pam; -use std::{ - collections::HashMap, - os::unix::process::CommandExt, - path::Path, - process::{Child, Command}, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{sync_channel, SyncSender}, - Arc, Mutex, - }, - time::{Duration, Instant}, -}; - -lazy_static::lazy_static! { - static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); - static ref DESKTOP_MANAGER: Arc>> = Arc::new(Mutex::new(None)); -} - -#[derive(Debug)] -struct DesktopManager { - seat0_username: String, - seat0_display_server: String, - child_username: String, - child_exit: Arc, - is_child_running: Arc, -} - -fn check_desktop_manager() { - let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); - if let Some(desktop_manager) = &mut (*desktop_manager) { - if desktop_manager.is_child_running.load(Ordering::SeqCst) { - return; - } - desktop_manager.child_exit.store(true, Ordering::SeqCst); - } -} - -pub fn start_xdesktop() { - debug_assert!(crate::is_server()); - std::thread::spawn(|| { - DesktopManager::recover_orphaned_session(); - *DESKTOP_MANAGER.lock().unwrap() = Some(DesktopManager::new()); - - let interval = time::Duration::from_millis(super::SERVICE_INTERVAL); - DESKTOP_RUNNING.store(true, Ordering::SeqCst); - while DESKTOP_RUNNING.load(Ordering::SeqCst) { - check_desktop_manager(); - std::thread::sleep(interval); - } - log::info!("xdesktop child thread exit"); - }); -} - -pub fn stop_xdesktop() { - DESKTOP_RUNNING.store(false, Ordering::SeqCst); - *DESKTOP_MANAGER.lock().unwrap() = None; -} - -fn detect_headless() -> Option<&'static str> { - match run_cmds(&format!("which {}", DesktopManager::get_xorg())) { - Ok(output) => { - if output.trim().is_empty() { - return Some(LOGIN_MSG_DESKTOP_XORG_NOT_FOUND); - } - } - _ => { - return Some(LOGIN_MSG_DESKTOP_XORG_NOT_FOUND); - } - } - - match run_cmds("ls /usr/share/xsessions/") { - Ok(output) => { - if output.trim().is_empty() { - return Some(LOGIN_MSG_DESKTOP_NO_DESKTOP); - } - } - _ => { - return Some(LOGIN_MSG_DESKTOP_NO_DESKTOP); - } - } - - None -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -enum XSessionStartErrorKind { - Auth, - Env, -} - -const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed"; - -#[derive(Debug)] -struct XSessionStartError { - kind: XSessionStartErrorKind, - detail: String, -} - -impl XSessionStartError { - fn auth(detail: String) -> Self { - Self { - kind: XSessionStartErrorKind::Auth, - detail, - } - } - - fn env(detail: String) -> Self { - Self { - kind: XSessionStartErrorKind::Env, - detail, - } - } -} - -impl std::fmt::Display for XSessionStartError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.detail) - } -} - -fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str { - match kind { - XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG, - XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED, - } -} - -pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { - debug_assert!(crate::is_server()); - if _username.is_empty() { - let username = get_username(); - if username.is_empty() { - if let Some(msg) = detect_headless() { - msg - } else { - LOGIN_MSG_DESKTOP_SESSION_NOT_READY - } - } else { - "" - } - .to_owned() - } else { - let username = get_username(); - if username == _username { - // No need to verify password here. - return "".to_owned(); - } - if !username.is_empty() { - // Another user is logged in. No need to start a new xsession. - return "".to_owned(); - } - - if let Some(msg) = detect_headless() { - return msg.to_owned(); - } - - match try_start_x_session(_username, _passsword) { - Ok((username, x11_ready)) => { - if x11_ready { - if _username != username { - LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER.to_owned() - } else { - "".to_owned() - } - } else { - LOGIN_MSG_DESKTOP_SESSION_NOT_READY.to_owned() - } - } - Err(e) => { - match e.kind { - XSessionStartErrorKind::Auth => { - log::warn!("Failed to authenticate xsession user {}", e); - } - XSessionStartErrorKind::Env => { - log::error!("Failed to start xsession {}", e); - } - } - map_xsession_start_error_to_login_msg(e.kind).to_owned() - } - } - } -} - -fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> { - 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() { - return Ok((seat0_username, true)); - } - - let _ = desktop_manager.try_start_x_session(username, password)?; - log::debug!( - "try_start_x_session, username: {}, {:?}", - &username, - &desktop_manager - ); - Ok(( - desktop_manager.child_username.clone(), - desktop_manager.is_running(), - )) - } else { - Err(XSessionStartError::env( - crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(), - )) - } -} - -#[inline] -pub fn is_headless() -> bool { - DESKTOP_MANAGER - .lock() - .unwrap() - .as_ref() - .map_or(false, |manager| { - manager.get_supported_display_seat0_username().is_none() - }) -} - -pub fn get_username() -> String { - match &*DESKTOP_MANAGER.lock().unwrap() { - Some(manager) => { - if let Some(seat0_username) = manager.get_supported_display_seat0_username() { - seat0_username - } else { - if manager.is_running() && !manager.child_username.is_empty() { - manager.child_username.clone() - } else { - "".to_owned() - } - } - } - None => "".to_owned(), - } -} - -impl Drop for DesktopManager { - fn drop(&mut self) { - self.stop_children(); - } -} - -impl DesktopManager { - fn fatal_exit() { - std::process::exit(0); - } - - 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"); - if xauth.is_empty() { - "/tmp/.Xauthority".to_owned() - } else { - xauth - } - } - - #[inline] - fn is_running(&self) -> bool { - self.is_child_running.load(Ordering::SeqCst) - } - - fn try_start_x_session( - &mut self, - username: &str, - password: &str, - ) -> Result<(), XSessionStartError> { - match get_user_by_name(username) { - Some(userinfo) => { - let mut client = pam::Client::with_password(&pam_get_service_name()) - .map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?; - client - .conversation_mut() - .set_credentials(username, password); - match client.authenticate() { - Ok(_) => { - if self.is_running() { - return Ok(()); - } - - match self.start_x_session(&userinfo, username, password) { - Ok(_) => { - log::info!("Succeeded to start x11"); - self.child_username = username.to_string(); - Ok(()) - } - Err(e) => { - Err(XSessionStartError::env(format!( - "failed to start x session, {}", - e - ))) - } - } - } - Err(_e) => { - Err(XSessionStartError::auth( - XSESSION_AUTH_FAILURE_DETAIL.to_owned(), - )) - } - } - } - None => { - Err(XSessionStartError::auth( - XSESSION_AUTH_FAILURE_DETAIL.to_owned(), - )) - } - } - } - - // The logic mainly from https://github.com/neutrinolabs/xrdp/blob/34fe9b60ebaea59e8814bbc3ca5383cabaa1b869/sesman/session.c#L334. - fn get_avail_display() -> ResultType { - let display_range = 0..51; - for i in display_range.clone() { - if Self::is_x_server_running(i) { - continue; - } - return Ok(i); - } - bail!("No available display found in range {:?}", display_range) - } - - #[inline] - fn is_x_server_running(display: u32) -> bool { - Path::new(&format!("/tmp/.X11-unix/X{}", display)).exists() - || Path::new(&format!("/tmp/.X{}-lock", display)).exists() - } - - fn start_x_session( - &mut self, - userinfo: &User, - username: &str, - password: &str, - ) -> ResultType<()> { - self.stop_children(); - - let display_num = Self::get_avail_display()?; - // "xServer_ip:display_num.screen_num" - - let uid = userinfo.uid(); - let gid = userinfo.primary_group_id(); - let envs = HashMap::from([ - ("SHELL", userinfo.shell().to_string_lossy().to_string()), - ("PATH", "/sbin:/bin:/usr/bin:/usr/local/bin".to_owned()), - ("USER", username.to_string()), - ("UID", userinfo.uid().to_string()), - ("HOME", userinfo.home_dir().to_string_lossy().to_string()), - ( - "XDG_RUNTIME_DIR", - format!("/run/user/{}", userinfo.uid().to_string()), - ), - // ("DISPLAY", self.display.clone()), - // ("XAUTHORITY", self.xauth.clone()), - // (ENV_DESKTOP_PROTOCOL, XProtocol::X11.to_string()), - ]); - self.child_exit.store(false, Ordering::SeqCst); - let is_child_running = self.is_child_running.clone(); - - let (tx_res, rx_res) = sync_channel(1); - let password = password.to_string(); - let username = username.to_string(); - // start x11 - std::thread::spawn(move || { - match Self::start_x_session_thread( - tx_res.clone(), - is_child_running, - uid, - gid, - display_num, - username, - password, - envs, - ) { - Ok(_) => {} - Err(e) => { - log::error!("Failed to start x session thread"); - allow_err!(tx_res.send(format!("Failed to start x session thread, {}", e))); - } - } - }); - - // wait x11 - match rx_res.recv_timeout(Duration::from_millis(10_000)) { - Ok(res) => { - if res == "" { - Ok(()) - } else { - bail!(res) - } - } - Err(e) => { - bail!("Failed to recv x11 result {}", e) - } - } - } - - #[inline] - fn display_from_num(num: u32) -> String { - format!(":{num}") - } - - fn start_x_session_thread( - tx_res: SyncSender, - is_child_running: Arc, - uid: u32, - gid: u32, - display_num: u32, - username: String, - password: String, - envs: HashMap<&str, String>, - ) -> ResultType<()> { - let mut client = pam::Client::with_password(&pam_get_service_name())?; - client - .conversation_mut() - .set_credentials(&username, &password); - client.authenticate()?; - - client.set_item(pam::PamItemType::TTY, &Self::display_from_num(display_num))?; - client.open_session()?; - - // fixme: FreeBSD kernel needs to login here. - // see: https://github.com/neutrinolabs/xrdp/blob/a64573b596b5fb07ca3a51590c5308d621f7214e/sesman/session.c#L556 - - let (child_xorg, child_wm) = Self::start_x11(uid, gid, username, display_num, &envs)?; - is_child_running.store(true, Ordering::SeqCst); - - // capture the logind session scope (from a live child) for teardown and crash - // recovery, see reap_session_scope and recover_orphaned_session. - let scope_dir = Self::session_scope_dir(child_xorg.id()); - Self::save_orphaned_marker(&scope_dir, display_num); - - log::info!("Start xorg and wm done, notify and wait xtop x11"); - allow_err!(tx_res.send("".to_owned())); - - Self::wait_stop_x11(child_xorg, child_wm, scope_dir, display_num); - log::info!("Wait x11 stop done"); - Ok(()) - } - - fn wait_xorg_exit(child_xorg: &mut Child) -> ResultType { - if let Ok(_) = child_xorg.kill() { - for _ in 0..3 { - match child_xorg.try_wait() { - Ok(Some(status)) => return Ok(format!("Xorg exit with {}", status)), - Ok(None) => {} - Err(e) => { - // fatal error - log::error!("Failed to wait xorg process, {}", e); - bail!("Failed to wait xorg process, {}", e) - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - log::error!("Failed to wait xorg process, not exit"); - bail!("Failed to wait xorg process, not exit") - } else { - Ok("Xorg is already exited".to_owned()) - } - } - - fn add_xauth_cookie( - file: &str, - display: &str, - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType<()> { - let randstr = (0..16) - .map(|_| format!("{:02x}", random::())) - .collect::(); - let output = Command::new("xauth") - .uid(uid) - .gid(gid) - .envs(envs) - .args(vec!["-q", "-f", file, "add", display, ".", &randstr]) - .output()?; - // xauth run success, even the following error occurs. - // Ok(Output { status: ExitStatus(unix_wait_status(0)), stdout: "", stderr: "xauth: file .Xauthority does not exist\n" }) - let errmsg = String::from_utf8_lossy(&output.stderr).to_string(); - if !errmsg.is_empty() { - if !errmsg.contains("does not exist") { - bail!("Failed to launch xauth, {}", errmsg) - } - } - Ok(()) - } - - fn wait_x_server_running(pid: u32, display_num: u32, max_wait_secs: u64) -> ResultType<()> { - let wait_begin = Instant::now(); - loop { - if run_cmds(&format!("ls /proc/{}", pid))?.is_empty() { - bail!("X server exit"); - } - - if Self::is_x_server_running(display_num) { - return Ok(()); - } - if wait_begin.elapsed().as_secs() > max_wait_secs { - bail!("Failed to wait xserver after {} seconds", max_wait_secs); - } - std::thread::sleep(Duration::from_millis(300)); - } - } - - fn start_x11( - uid: u32, - gid: u32, - username: String, - display_num: u32, - envs: &HashMap<&str, String>, - ) -> ResultType<(Child, Child)> { - log::debug!("envs of user {}: {:?}", &username, &envs); - - let xauth = Self::get_xauth(); - let display = Self::display_from_num(display_num); - - Self::add_xauth_cookie(&xauth, &display, uid, gid, &envs)?; - - // Start Xorg - let mut child_xorg = Self::start_x_server(&xauth, &display, uid, gid, &envs)?; - - log::info!("xorg started, wait 10 secs to ensuer x server is running"); - - let max_wait_secs = 10; - // wait x server running - if let Err(e) = Self::wait_x_server_running(child_xorg.id(), display_num, max_wait_secs) { - match Self::wait_xorg_exit(&mut child_xorg) { - Ok(msg) => log::info!("{}", msg), - Err(e) => { - log::error!("{}", e); - Self::fatal_exit(); - } - } - bail!(e) - } - - log::info!( - "xorg is running, start x window manager with DISPLAY: {}, XAUTHORITY: {}", - &display, - &xauth - ); - - std::env::set_var("DISPLAY", &display); - std::env::set_var("XAUTHORITY", &xauth); - // start window manager (startwm.sh) - let child_wm = match Self::start_x_window_manager(uid, gid, &envs) { - Ok(c) => c, - Err(e) => { - match Self::wait_xorg_exit(&mut child_xorg) { - Ok(msg) => log::info!("{}", msg), - Err(e) => { - log::error!("{}", e); - Self::fatal_exit(); - } - } - bail!(e) - } - }; - log::info!("x window manager is started"); - - Ok((child_xorg, child_wm)) - } - - fn try_wait_x11_child_exit(child_xorg: &mut Child, child_wm: &mut Child) -> bool { - match child_xorg.try_wait() { - Ok(Some(status)) => { - log::info!("Xorg exit with {}", status); - return true; - } - Ok(None) => {} - Err(e) => log::error!("Failed to wait xorg process, {}", e), - } - - match child_wm.try_wait() { - Ok(Some(status)) => { - // Logout may result "wm exit with signal: 11 (SIGSEGV) (core dumped)" - log::info!("wm exit with {}", status); - return true; - } - Ok(None) => {} - Err(e) => log::error!("Failed to wait xorg process, {}", e), - } - false - } - - fn wait_x11_children_exit(child_xorg: &mut Child, child_wm: &mut Child) { - log::debug!("Try kill child process xorg"); - if let Ok(_) = child_xorg.kill() { - let mut exited = false; - for _ in 0..2 { - match child_xorg.try_wait() { - Ok(Some(status)) => { - log::info!("Xorg exit with {}", status); - exited = true; - break; - } - Ok(None) => {} - Err(e) => { - log::error!("Failed to wait xorg process, {}", e); - Self::fatal_exit(); - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - if !exited { - log::error!("Failed to wait child xorg, after kill()"); - // try kill -9? - } - } - log::debug!("Try kill child process wm"); - if let Ok(_) = child_wm.kill() { - let mut exited = false; - for _ in 0..2 { - match child_wm.try_wait() { - Ok(Some(status)) => { - // Logout may result "wm exit with signal: 11 (SIGSEGV) (core dumped)" - log::info!("wm exit with {}", status); - exited = true; - } - Ok(None) => {} - Err(e) => { - log::error!("Failed to wait wm process, {}", e); - Self::fatal_exit(); - } - } - std::thread::sleep(std::time::Duration::from_millis(1_000)); - } - if !exited { - log::error!("Failed to wait child xorg, after kill()"); - // try kill -9? - } - } - } - - // resolve the "session-.scope" directory pam_systemd put the x session in, read - // from a live child pid. cgroup v2 mounts every cgroup under /sys/fs/cgroup, v1/hybrid - // keeps the scope under the systemd controller mount; pick by the controller field and - // confirm the cgroup is real. empty if there is no such scope (e.g. no logind). - fn session_scope_dir(pid: u32) -> String { - let path = format!("/proc/{}/cgroup", pid); - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - log::warn!("Failed to read {} to find session scope: {}", path, e); - return "".to_owned(); - } - }; - for line in content.lines() { - // "::"; v2 unified is "0::", the v1 - // systemd hierarchy is ":name=systemd:". - let mut fields = line.splitn(3, ':'); - let (controllers, cgroup) = match (fields.next(), fields.next(), fields.next()) { - (Some(_), Some(c), Some(p)) => (c, p), - _ => continue, - }; - let scope = match Self::session_scope(cgroup) { - Some(s) => s, - None => continue, - }; - let mount = if controllers.is_empty() { - "/sys/fs/cgroup" - } else if controllers.split(',').any(|c| c == "name=systemd") { - "/sys/fs/cgroup/systemd" - } else { - continue; - }; - let dir = format!("{}{}", mount, scope); - if Path::new(&format!("{}/cgroup.procs", dir)).exists() { - return dir; - } - } - "".to_owned() - } - - // the "/.../session-.scope" prefix of a cgroup path, dropping any nested child - // cgroup below it so a descendant scope does not get mistaken for the session. - fn session_scope(cgroup: &str) -> Option { - let mut scope = String::new(); - for comp in cgroup.split('/').filter(|c| !c.is_empty()) { - scope.push('/'); - scope.push_str(comp); - if comp.starts_with("session-") && comp.ends_with(".scope") { - return Some(scope); - } - } - None - } - - // on teardown reap the whole session scope subtree, not just the xorg + wm pids: - // the per-session pipewire and other desktop children otherwise outlive them and - // hold the logind session in "closing", leaking sockets + displays on reconnect - // (rustdesk/rustdesk#15183). SIGTERM first so pipewire unlinks its sockets, then - // SIGKILL stragglers; skip our own pid (pam put the service in the scope too). - fn reap_session_scope(scope_dir: &str) { - if scope_dir.is_empty() { - return; - } - let me = std::process::id(); - // spare the --server's own children and any descendants of them sharing this scope - // (see pid_is_spared); only the desktop session's leftovers are reaped. - let spared: Vec = crate::server::CHILD_PROCESS - .lock() - .unwrap() - .iter() - .map(|c| c.id()) - .collect(); - for sig in [hbb_common::libc::SIGTERM, hbb_common::libc::SIGKILL] { - let mut pids = Vec::new(); - Self::collect_scope_pids(Path::new(scope_dir), &mut pids); - let mut any = false; - for pid in pids { - if pid == me || Self::pid_is_spared(pid, &spared, me) { - continue; - } - any = true; - log::info!("Reaping leftover session process {} (signal {})", pid, sig); - unsafe { - if hbb_common::libc::kill(pid as hbb_common::libc::pid_t, sig) != 0 { - let err = std::io::Error::last_os_error(); - // ESRCH = it already exited (or did between snapshot and now). - if err.raw_os_error() != Some(hbb_common::libc::ESRCH) { - log::warn!("Failed to signal session process {}: {}", pid, err); - } - } - } - } - if !any { - break; - } - if sig == hbb_common::libc::SIGTERM { - std::thread::sleep(Duration::from_millis(300)); - } - } - } - - // a tracked --server child (the sudo wrapper run_as_user spawns) or any descendant of - // one: with use_pty sudo runs --cm-no-ui under a monitor with its own pid, so walk the - // parent chain (stopping at the --server) to spare the worker, not just the wrapper. - fn pid_is_spared(pid: u32, spared: &[u32], me: u32) -> bool { - let mut cur = pid; - for _ in 0..32 { - if spared.contains(&cur) { - return true; - } - if cur <= 1 || cur == me { - return false; - } - match Self::parent_pid(cur) { - Some(ppid) => cur = ppid, - None => return false, - } - } - false - } - - fn parent_pid(pid: u32) -> Option { - // /proc//stat is "pid (comm) state ppid ..."; comm can contain spaces and ')', - // so read the fields after the last ')'. - let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; - stat.rsplit_once(')')? - .1 - .split_whitespace() - .nth(1)? - .parse() - .ok() - } - - // collect every pid in the cgroup subtree rooted at dir. "cgroup.procs" lists only - // the procs directly in a cgroup, so recurse into child cgroup directories to catch - // processes the desktop session moved into descendant scopes. - fn collect_scope_pids(dir: &Path, out: &mut Vec) { - let procs = dir.join("cgroup.procs"); - match std::fs::read_to_string(&procs) { - Ok(content) => { - out.extend(content.lines().filter_map(|l| l.trim().parse::().ok())); - } - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to read {}: {}", procs.display(), e); - } - Err(_) => {} - } - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to list cgroup dir {}: {}", dir.display(), e); - return; - } - Err(_) => return, - }; - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(e) => { - log::warn!("Failed to read entry under {}: {}", dir.display(), e); - continue; - } - }; - match entry.file_type() { - Ok(t) if t.is_dir() => Self::collect_scope_pids(&entry.path(), out), - Ok(_) => {} - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - log::warn!("Failed to stat {}: {}", entry.path().display(), e); - } - Err(_) => {} - } - } - } - - // a SIGKILL'd Xorg (how wait_x11_children_exit ends it) leaves "/tmp/.X-lock" and - // "/tmp/.X11-unix/X" behind, and get_avail_display() treats either file as "display - // in use", so the number is never reused and climbs until none are free - // (rustdesk/rustdesk#15183). a clean exit would remove them; do the same on teardown, - // but skip it if a live process still holds the lock: another server could have taken - // the number in the gap, and removing its files would break that display. - fn cleanup_x_display_files(display_num: u32) { - let lock = format!("/tmp/.X{}-lock", display_num); - if let Ok(content) = std::fs::read_to_string(&lock) { - if let Ok(pid) = content.trim().parse::() { - if Self::pid_alive(pid) { - log::info!("X display {} still held by pid {}, leaving its files", display_num, pid); - return; - } - } - } - for path in [lock, format!("/tmp/.X11-unix/X{}", display_num)] { - if let Err(e) = std::fs::remove_file(&path) { - if e.kind() != std::io::ErrorKind::NotFound { - log::warn!("Failed to remove stale X file {}: {}", path, e); - } - } - } - } - - // signal-0 probe: the pid exists if kill succeeds or fails with EPERM (alive but not - // ours); only ESRCH means it is gone. - fn pid_alive(pid: i32) -> bool { - unsafe { - if hbb_common::libc::kill(pid as hbb_common::libc::pid_t, 0) == 0 { - return true; - } - } - std::io::Error::last_os_error().raw_os_error() == Some(hbb_common::libc::EPERM) - } - - const ORPHANED_SESSION_KEY: &'static str = "headless-orphaned-session"; - - fn save_orphaned_marker(scope_dir: &str, display_num: u32) { - // tag the marker with this boot's id: a logind session id is only unique within a - // boot (the counter lives in /run and resets), so recovery must not reap a recorded - // scope path after a reboot, when it may name a different live session. - let boot_id = Self::current_boot_id().unwrap_or_default(); - hbb_common::config::LocalConfig::set_option( - Self::ORPHANED_SESSION_KEY.to_owned(), - format!("{};{};{}", scope_dir, display_num, boot_id), - ); - } - - fn current_boot_id() -> Option { - std::fs::read_to_string("/proc/sys/kernel/random/boot_id") - .ok() - .map(|s| s.trim().to_owned()) - } - - fn clear_orphaned_marker() { - hbb_common::config::LocalConfig::set_option( - Self::ORPHANED_SESSION_KEY.to_owned(), - String::new(), - ); - } - - fn parse_orphaned_marker(marker: &str) -> Option<(&str, u32, &str)> { - let (rest, boot_id) = marker.rsplit_once(';')?; - let (scope_dir, display) = rest.rsplit_once(';')?; - Some((scope_dir, display.trim().parse::().ok()?, boot_id)) - } - - // a run that dies before wait_stop_x11 (service or --server crash) leaks the headless - // session scope + X lock files, the same as a missed teardown (rustdesk/rustdesk#15183). - // reap exactly what the dead run recorded - never a scan, so unrelated sessions are safe. - fn recover_orphaned_session() { - let marker = hbb_common::config::LocalConfig::get_option(Self::ORPHANED_SESSION_KEY); - if marker.is_empty() { - return; - } - if let Some((scope_dir, display_num, boot_id)) = Self::parse_orphaned_marker(&marker) { - // only reap the recorded scope when the marker is from this same boot: a leaked - // cgroup cannot outlive a reboot, so cross-boot there is nothing legitimate to - // reap, and the recorded "session-N.scope" may by then name a different live - // session. the X lock cleanup is pid-guarded, so run it either way. - let same_boot = Self::current_boot_id().map_or(false, |b| b == boot_id); - log::info!( - "Recovering leaked headless session from a previous run: scope {}, display {} (same boot: {})", - scope_dir, - display_num, - same_boot - ); - if same_boot { - Self::reap_session_scope(scope_dir); - } - Self::cleanup_x_display_files(display_num); - } - Self::clear_orphaned_marker(); - } - - fn try_wait_stop_x11( - child_xorg: &mut Child, - child_wm: &mut Child, - scope_dir: &str, - display_num: u32, - ) -> bool { - let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); - let mut exited = true; - if let Some(desktop_manager) = &mut (*desktop_manager) { - if desktop_manager.child_exit.load(Ordering::SeqCst) { - exited = true; - } else { - exited = Self::try_wait_x11_child_exit(child_xorg, child_wm); - } - if exited { - log::debug!("Wait x11 children exiting"); - Self::wait_x11_children_exit(child_xorg, child_wm); - Self::reap_session_scope(scope_dir); - Self::cleanup_x_display_files(display_num); - Self::clear_orphaned_marker(); - desktop_manager - .is_child_running - .store(false, Ordering::SeqCst); - desktop_manager.child_exit.store(true, Ordering::SeqCst); - } - } - exited - } - - fn wait_stop_x11( - mut child_xorg: Child, - mut child_wm: Child, - scope_dir: String, - display_num: u32, - ) { - loop { - if Self::try_wait_stop_x11(&mut child_xorg, &mut child_wm, &scope_dir, display_num) { - break; - } - std::thread::sleep(Duration::from_millis(super::SERVICE_INTERVAL)); - } - } - - fn get_xorg() -> &'static str { - // Fedora 26 or later - let xorg = "/usr/libexec/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Debian 9 or later - let xorg = "/usr/lib/xorg/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Ubuntu 16.04 or later - let xorg = "/usr/lib/xorg/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Arch Linux - let xorg = "/usr/lib/xorg-server/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // Arch Linux - let xorg = "/usr/lib/Xorg"; - if Path::new(xorg).is_file() { - return xorg; - } - // CentOS 7 /usr/bin/Xorg or param=Xorg - - log::warn!("Failed to find xorg, use default Xorg.\n Please add \"allowed_users=anybody\" to \"/etc/X11/Xwrapper.config\"."); - "Xorg" - } - - fn start_x_server( - xauth: &str, - display: &str, - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType { - let xorg = Self::get_xorg(); - log::info!("Use xorg: {}", &xorg); - let app_name = crate::get_app_name().to_lowercase(); - let conf = format!("/etc/{app_name}/xorg.conf"); - match Command::new(xorg) - .envs(envs) - .uid(uid) - .gid(gid) - .args(vec![ - "-noreset", - "+extension", - "GLX", - "+extension", - "RANDR", - "+extension", - "RENDER", - "-config", - conf.as_ref(), - "-auth", - xauth, - display, - ]) - .spawn() - { - Ok(c) => Ok(c), - Err(e) => { - bail!("Failed to start Xorg with display {}, {}", display, e); - } - } - } - - fn start_x_window_manager( - uid: u32, - gid: u32, - envs: &HashMap<&str, String>, - ) -> ResultType { - let app_name = crate::get_app_name().to_lowercase(); - match Command::new(&format!("/etc/{app_name}/startwm.sh")) - .envs(envs) - .uid(uid) - .gid(gid) - .spawn() - { - Ok(c) => Ok(c), - Err(e) => { - bail!("Failed to start window manager, {}", e); - } - } - } - - fn stop_children(&mut self) { - self.child_exit.store(true, Ordering::SeqCst); - for _i in 1..10 { - if !self.is_child_running.load(Ordering::SeqCst) { - break; - } - std::thread::sleep(Duration::from_millis(super::SERVICE_INTERVAL)); - } - if self.is_child_running.load(Ordering::SeqCst) { - log::warn!("xdesktop child is still running!"); - } - } -} - -fn pam_get_service_name() -> String { - let app_name = crate::get_app_name().to_lowercase(); - if Path::new(&format!("/etc/pam.d/{app_name}")).is_file() { - app_name - } else { - "gdm".to_owned() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_scope_truncates_at_first_scope() { - assert_eq!( - DesktopManager::session_scope("/user.slice/user-1000.slice/session-3.scope").as_deref(), - Some("/user.slice/user-1000.slice/session-3.scope") - ); - // a nested child scope must not be mistaken for the session - assert_eq!( - DesktopManager::session_scope( - "/user.slice/user-1000.slice/session-3.scope/app-foo.scope" - ) - .as_deref(), - Some("/user.slice/user-1000.slice/session-3.scope") - ); - assert_eq!( - DesktopManager::session_scope( - "/user.slice/user-1000.slice/user@1000.service/app.slice/x.service" - ), - None - ); - assert_eq!(DesktopManager::session_scope("/"), None); - } - - #[test] - fn collect_scope_pids_walks_descendant_cgroups() { - // regression for #15183: pids in descendant cgroups must be collected too - let base = std::env::temp_dir().join(format!("rustdesk-cgtest-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let scope = base.join("session-3.scope"); - let child = scope.join("app-foo.scope"); - let nested = child.join("deeper.scope"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::create_dir_all(scope.join("empty.scope")).unwrap(); - std::fs::write(scope.join("cgroup.procs"), "100\n101\n").unwrap(); - std::fs::write(scope.join("cgroup.controllers"), "memory pids\n").unwrap(); - std::fs::write(child.join("cgroup.procs"), "200\n").unwrap(); - std::fs::write(nested.join("cgroup.procs"), "300\n").unwrap(); - - let mut pids = Vec::new(); - DesktopManager::collect_scope_pids(&scope, &mut pids); - pids.sort(); - let _ = std::fs::remove_dir_all(&base); - - assert_eq!(pids, vec![100, 101, 200, 300]); - } - - #[test] - fn parses_orphaned_session_marker() { - assert_eq!( - DesktopManager::parse_orphaned_marker( - "/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope;7;abc-123" - ), - Some(( - "/sys/fs/cgroup/user.slice/user-1000.slice/session-3.scope", - 7, - "abc-123" - )) - ); - // an empty scope still carries the display so its stale X lock can be cleaned - assert_eq!(DesktopManager::parse_orphaned_marker(";5;abc-123"), Some(("", 5, "abc-123"))); - // an empty boot id never matches the live one, so the scope reap is skipped - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;5;"), Some(("/scope", 5, ""))); - assert_eq!(DesktopManager::parse_orphaned_marker(""), None); - assert_eq!(DesktopManager::parse_orphaned_marker("garbage"), None); - // the pre-boot-id two-field format no longer parses, recovery just skips it - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;7"), None); - assert_eq!(DesktopManager::parse_orphaned_marker("/scope;notnum;abc"), None); - } -} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c1bc38232..d55005b4d 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -20,9 +20,6 @@ pub mod delegate; #[cfg(target_os = "linux")] pub mod linux; -#[cfg(target_os = "linux")] -pub mod linux_desktop_manager; - #[cfg(target_os = "linux")] pub mod gtk_sudo; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 5253895dd..998bd6bad 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -100,6 +100,7 @@ use winreg::{enums::*, RegKey}; mod acl; mod installer_handoff; mod installer_shell; +mod msi_registry; pub(crate) use acl::current_process_user_sid_string; pub use acl::{ set_path_permission, set_path_permission_for_portable_service_shmem_dir, @@ -119,6 +120,13 @@ pub const SET_FOREGROUND_WINDOW: &'static str = "SET_FOREGROUND_WINDOW"; const REG_NAME_INSTALL_DESKTOPSHORTCUTS: &str = "DESKTOPSHORTCUTS"; const REG_NAME_INSTALL_STARTMENUSHORTCUTS: &str = "STARTMENUSHORTCUTS"; pub const REG_NAME_INSTALL_PRINTER: &str = "PRINTER"; +const REG_NAME_MSI_PRODUCT_CODE: &str = "MsiProductCode"; +const REG_NAME_UNINSTALL_STRING: &str = "UninstallString"; +const REG_NAME_WINDOWS_INSTALLER: &str = "WindowsInstaller"; +const MSI_WINDOWS_INSTALLER_VALUE: u32 = 1; +const MSI_EXIT_SUCCESS_REBOOT_INITIATED: u32 = 1641; +const MSI_EXIT_SUCCESS_REBOOT_REQUIRED: u32 = 3010; +const HKLM_PREFIX: &str = "HKEY_LOCAL_MACHINE\\"; fn validate_install_app_name(app_name: &str) -> ResultType<()> { if app_name.is_empty() @@ -1305,6 +1313,11 @@ fn get_subkey(name: &str, wow: bool) -> String { } fn get_valid_subkey() -> String { + let app_name = crate::get_app_name(); + let subkey = format!("{HKLM_PREFIX}Software\\{app_name}\\InstallState\\{app_name}"); + if !get_reg_of(&subkey, "InstallLocation").is_empty() { + return subkey; + } let subkey = get_subkey(IS1, false); if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; @@ -1313,7 +1326,6 @@ fn get_valid_subkey() -> String { if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; } - let app_name = crate::get_app_name(); let subkey = get_subkey(&app_name, true); if !get_reg_of(&subkey, "InstallLocation").is_empty() { return subkey; @@ -1572,7 +1584,12 @@ fn get_after_install( } pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> ResultType<()> { - let uninstall_str = get_uninstall(false, false); + // MSI and EXE installations use different registry layouts, so MSI-to-EXE upgrades are not supported. + let (installed_subkey, _, _, _) = get_install_info(); + if get_windows_installer_state(&installed_subkey)? == Some(true) { + bail!("Cannot install the EXE package over an existing MSI installation"); + } + let uninstall_str = get_uninstall(false, false)?; let mut path = path.trim_end_matches('\\').to_owned(); let (subkey, _path, start_menu, exe) = get_default_install_info(); let mut exe = exe; @@ -1804,10 +1821,14 @@ fn get_before_uninstall(kill_self: bool) -> String { /// The `uninstall_printer` parameter determines whether the command to uninstall the remote printer /// is included in the generated uninstall script. If `uninstall_printer` is `false`, the printer /// related command is omitted from the script. -fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { - let reg_uninstall_string = get_reg("UninstallString"); - if reg_uninstall_string.to_lowercase().contains("msiexec.exe") { - return reg_uninstall_string; +fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> ResultType { + let (subkey, path, start_menu, _) = get_install_info(); + let installer_state = get_windows_installer_state(&subkey)?; + if let Some(product_code) = get_msi_product_code(&subkey, installer_state)? { + return Ok(build_msi_uninstall_command(&product_code)); + } + if installer_state == Some(true) { + bail!("MSI product code was not found in {subkey}"); } let mut uninstall_cert_cmd = "".to_string(); @@ -1820,8 +1841,7 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { } } } - let (subkey, path, start_menu, _) = get_install_info(); - format!( + Ok(format!( " {before_uninstall} {uninstall_printer_cmd} @@ -1836,11 +1856,11 @@ fn get_uninstall(kill_self: bool, uninstall_printer: bool) -> String { before_uninstall=get_before_uninstall(kill_self), uninstall_amyuni_idd=get_uninstall_amyuni_idd(), app_name = crate::get_app_name(), - ) + )) } pub fn uninstall_me(kill_self: bool) -> ResultType<()> { - run_cmds(get_uninstall(kill_self, true), true, "uninstall") + run_cmds(get_uninstall(kill_self, true)?, true, "uninstall") } fn write_vbs(cmds: String, tip: &str) -> ResultType { @@ -2642,6 +2662,91 @@ pub fn wide_string(s: &str) -> Vec { .collect() } +// This only changes mstsc's top-level window title. The full-screen connection +// bar is rendered separately and cannot be customized when mstsc.exe is +// launched as an independent process. +pub fn set_rdp_window_title(mut child: std::process::Child, name: String) { + let name: String = name.chars().filter(|c| !c.is_control()).take(120).collect(); + if name.is_empty() { + return; + } + let process_id = child.id(); + // mstsc owns the title and can restore "localhost" while connecting or + // reconnecting. Follow only the process we launched and reapply the peer + // name until it exits, so concurrent RDP sessions cannot rename each other. + if let Err(err) = std::thread::Builder::new() + .name("rdp-window-title".to_owned()) + .spawn(move || { + let mut warned = false; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Err(err) => { + log::warn!("Failed to query mstsc process: {}", err); + break; + } + Ok(None) => match set_process_rdp_window_title(process_id, &name) { + Ok(()) => warned = false, + Err(err) if !warned => { + log::warn!("Failed to set RDP window title: {}", err); + warned = true; + } + Err(_) => {} + }, + } + std::thread::sleep(Duration::from_millis(500)); + } + }) + { + log::warn!("Failed to start RDP window title thread: {}", err); + } +} + +fn set_process_rdp_window_title(process_id: DWORD, name: &str) -> io::Result<()> { + struct Context { + process_id: DWORD, + title: Vec, + error: Option, + } + + unsafe extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> BOOL { + let context = &mut *(lparam as *mut Context); + let mut window_process_id = 0; + GetWindowThreadProcessId(hwnd, &mut window_process_id); + if window_process_id != context.process_id || IsWindowVisible(hwnd) == FALSE { + return TRUE; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return TRUE; + } + let mut title = vec![0u16; len as usize + 1]; + let len = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as _); + if len > 0 && String::from_utf16_lossy(&title[..len as usize]).contains("localhost") { + if SetWindowTextW(hwnd, context.title.as_ptr()) == FALSE { + context.error = Some(io::Error::last_os_error()); + return FALSE; + } + } + TRUE + } + + let mut context = Context { + process_id, + title: wide_string(name), + error: None, + }; + let enumerated = + unsafe { EnumWindows(Some(enum_window), &mut context as *mut Context as LPARAM) }; + if let Some(err) = context.error { + return Err(err); + } + if enumerated == FALSE { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + /// send message to currently shown window pub fn send_message_to_hnwd( class_name: &str, @@ -3304,6 +3409,8 @@ pub fn update_me(debug: bool) -> ResultType<()> { if !is_installed { bail!("{} is not installed.", &app_name); } + let is_msi = is_msi_installed().ok(); + let reg_msi_key = get_reg_msi_key(&subkey, is_msi)?; let app_exe_name = &format!("{}.exe", &app_name); // NOTE: The pids below are matched by command line, which can silently come @@ -3354,8 +3461,6 @@ pub fn update_me(debug: bool) -> ResultType<()> { // Use the icon in the previous installation directory if possible. let display_icon = get_custom_icon("", &exe).unwrap_or(exe.to_string()); - let is_msi = is_msi_installed().ok(); - fn get_reg_cmd( subkey: &str, is_msi: Option, @@ -3401,18 +3506,10 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} &version_build, size, ); - let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) { - get_reg_cmd( - ®_msi_key, - is_msi, - &display_icon, - &version, - &build_date, - &version_major, - &version_minor, - &version_build, - size, - ) + let reg_cmd_msi = if let Some(reg_msi_key) = ®_msi_key { + // This is best-effort: failure may leave a stale version in the Windows app list, + // but should not interrupt the update. + format!("reg add {reg_msi_key} /f /v DisplayVersion /t REG_SZ /d \"{version}\"") } else { "".to_owned() }; @@ -3536,34 +3633,147 @@ taskkill /F /IM {app_name}.exe{filter} Ok(()) } -fn get_reg_msi_key(subkey: &str, is_msi: Option) -> Option { +fn normalize_msi_product_code(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + let value = value.strip_prefix('{')?.strip_suffix('}')?; + let product_code = uuid::Uuid::parse_str(value).ok()?; + Some(format!("{{{}}}", product_code.hyphenated()).to_uppercase()) +} + +fn build_msi_uninstall_command(product_code: &str) -> String { + format!( + "set \"RUSTDESK_MSI_EXIT_CODE=\"\n\ +MsiExec.exe /X {product_code} /norestart REBOOT=ReallySuppress\n\ +set \"RUSTDESK_MSI_EXIT_CODE=%ERRORLEVEL%\"\n\ +if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" echo MSI uninstall succeeded with a reboot recommendation; continuing without reboot.\n\ +if \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" echo MSI uninstall succeeded with a reboot request; continuing without forcing reboot.\n\ +if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"0\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_REQUIRED}\" if not \"%RUSTDESK_MSI_EXIT_CODE%\"==\"{MSI_EXIT_SUCCESS_REBOOT_INITIATED}\" exit /b %RUSTDESK_MSI_EXIT_CODE%\n\ +ver > nul" + ) +} + +fn get_reg_string_of(subkey: &str, name: &str) -> ResultType> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey(path) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => bail!("Failed to open registry key {subkey}: {err}"), + }; + match key.get_value::(name) { + Ok(value) => Ok(Some(value)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => bail!("Failed to read {name} from registry key {subkey}: {err}"), + } +} + +fn get_windows_installer_state(subkey: &str) -> ResultType> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey(path) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => bail!("Failed to open registry key {subkey}: {err}"), + }; + match key.get_value::(REG_NAME_WINDOWS_INSTALLER) { + Ok(value) => Ok(Some(value == MSI_WINDOWS_INSTALLER_VALUE)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => bail!("Failed to read {REG_NAME_WINDOWS_INSTALLER} from {subkey}: {err}"), + } +} + +fn parse_msi_product_code_from_uninstall_string( + uninstall_string: &str, + subkey: &str, +) -> ResultType> { + if !uninstall_string + .to_ascii_lowercase() + .contains("msiexec.exe") + { + return Ok(None); + } + let start = uninstall_string + .rfind('{') + .ok_or_else(|| anyhow!("MSI uninstall string has no product code in {subkey}"))?; + let end = uninstall_string + .rfind('}') + .ok_or_else(|| anyhow!("MSI uninstall string has no product code in {subkey}"))?; + if start >= end { + bail!("Invalid MSI uninstall string in {subkey}"); + } + let product_code = uninstall_string + .get(start..=end) + .and_then(normalize_msi_product_code) + .ok_or_else(|| anyhow!("Invalid MSI uninstall string in {subkey}"))?; + Ok(Some(product_code)) +} + +fn get_msi_product_code(subkey: &str, installer_state: Option) -> ResultType> { + if installer_state == Some(false) { + return Ok(None); + } + let product_code = get_reg_string_of(subkey, REG_NAME_MSI_PRODUCT_CODE)?; + if let Some(product_code) = product_code.filter(|value| !value.is_empty()) { + return normalize_msi_product_code(&product_code) + .map(Some) + .ok_or_else(|| anyhow!("Invalid MSI product code in {subkey}")); + } + + let uninstall_string = + get_reg_string_of(subkey, REG_NAME_UNINSTALL_STRING)?.unwrap_or_default(); + match parse_msi_product_code_from_uninstall_string(&uninstall_string, subkey)? { + Some(product_code) => Ok(Some(product_code)), + None if installer_state == Some(true) => { + msi_registry::find_product_code(&crate::get_app_name()) + } + None => Ok(None), + } +} + +fn is_msi_uninstall_entry_in_view(subkey: &str, wow: bool, app_name: &str) -> ResultType { + let flags = KEY_READ + | if wow { + KEY_WOW64_32KEY + } else { + KEY_WOW64_64KEY + }; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = subkey.strip_prefix(HKLM_PREFIX).unwrap_or(subkey); + let key = match hklm.open_subkey_with_flags(path, flags) { + Ok(key) => key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(anyhow!("Failed to open registry key {subkey}: {err}")), + }; + msi_registry::is_matching_entry(&key, app_name, subkey) +} + +fn get_msi_uninstall_subkey(product_code: &str) -> ResultType { + let app_name = crate::get_app_name(); + let subkey = get_subkey(product_code, false); + if is_msi_uninstall_entry_in_view(&subkey, false, &app_name)? { + return Ok(subkey); + } + if is_msi_uninstall_entry_in_view(&subkey, true, &app_name)? { + return Ok(get_subkey(product_code, true)); + } + bail!("Matching native MSI uninstall entry {product_code} was not found") +} + +fn get_reg_msi_key(subkey: &str, is_msi: Option) -> ResultType> { // Only proceed if it's a custom client and MSI is installed. // `is_msi.unwrap_or(true)` is intentional: subsequent code validates the registry, // hence no early return is required upon MSI detection failure. if !(crate::common::is_custom_client() && is_msi.unwrap_or(true)) { - return None; + return Ok(None); } - // Get the uninstall string from registry - let uninstall_string = get_reg_of(subkey, "UninstallString"); - if uninstall_string.is_empty() { - return None; - } - - // Find the product code (GUID) in the uninstall string - // Handle both quoted and unquoted GUIDs: /X {GUID} or /X "{GUID}" - let start = uninstall_string.rfind('{')?; - let end = uninstall_string.rfind('}')?; - if start >= end { - return None; - } - let product_code = &uninstall_string[start..=end]; - - // Build the MSI registry key path - let pos = subkey.rfind('\\')?; - let reg_msi_key = format!("{}{}", &subkey[..=pos], product_code); - - Some(reg_msi_key) + let Some(product_code) = get_msi_product_code(subkey, is_msi)? else { + if is_msi == Some(true) { + bail!("MSI product code was not found in {subkey}"); + } + return Ok(None); + }; + Ok(Some(get_msi_uninstall_subkey(&product_code)?)) } // Double confirm the process name @@ -4337,12 +4547,11 @@ fn get_pids>(name: S) -> ResultType> { } pub fn is_msi_installed() -> std::io::Result { + let (subkey, _, _, _) = get_install_info(); let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let uninstall_key = hklm.open_subkey(format!( - "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", - crate::get_app_name() - ))?; - Ok(1 == uninstall_key.get_value::("WindowsInstaller")?) + let install_key = hklm.open_subkey(subkey.strip_prefix(HKLM_PREFIX).unwrap_or(&subkey))?; + Ok(MSI_WINDOWS_INSTALLER_VALUE + == install_key.get_value::(REG_NAME_WINDOWS_INSTALLER)?) } pub fn is_cur_exe_the_installed() -> bool { diff --git a/src/platform/windows/msi_registry.rs b/src/platform/windows/msi_registry.rs new file mode 100644 index 000000000..ef08f17fb --- /dev/null +++ b/src/platform/windows/msi_registry.rs @@ -0,0 +1,96 @@ +use super::{ + normalize_msi_product_code, ResultType, MSI_WINDOWS_INSTALLER_VALUE, REG_NAME_WINDOWS_INSTALLER, +}; +use hbb_common::{anyhow::anyhow, bail, log}; +use std::collections::BTreeSet; +use winreg::{enums::*, RegKey}; + +const REG_NAME_DISPLAY_NAME: &str = "DisplayName"; +const UNINSTALL_SUBKEY: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; + +pub(super) fn find_product_code(app_name: &str) -> ResultType> { + let product_codes = find_product_codes_in_view(app_name, false)? + .into_iter() + .chain(find_product_codes_in_view(app_name, true)?) + .collect::>(); + let mut product_codes = product_codes.into_iter(); + let product_code = product_codes.next(); + if product_codes.next().is_some() { + bail!("Multiple native MSI uninstall entries were found for {app_name}"); + } + Ok(product_code) +} + +fn find_product_codes_in_view(app_name: &str, wow: bool) -> ResultType> { + let flags = KEY_READ + | if wow { + KEY_WOW64_32KEY + } else { + KEY_WOW64_64KEY + }; + let view_name = if wow { "32-bit" } else { "64-bit" }; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let uninstall_key = match hklm.open_subkey_with_flags(UNINSTALL_SUBKEY, flags) { + Ok(uninstall_key) => uninstall_key, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => bail!("Failed to open {view_name} MSI uninstall registry: {err}"), + }; + let mut matches = Vec::new(); + + for key_name in uninstall_key.enum_keys() { + let key_name = match key_name { + Ok(key_name) => key_name, + Err(err) => { + log::warn!("Skipping unreadable {view_name} MSI uninstall key name: {err}"); + continue; + } + }; + let Some(product_code) = normalize_msi_product_code(&key_name) else { + continue; + }; + let is_match = uninstall_key + .open_subkey_with_flags(&key_name, flags) + .map_err(|err| { + anyhow!("Failed to open {view_name} MSI uninstall entry {key_name}: {err}") + }) + .and_then(|entry| is_matching_entry(&entry, app_name, &key_name)); + if scanned_entry_matches(is_match) { + matches.push(product_code); + } + } + + Ok(matches) +} + +pub(super) fn scanned_entry_matches(result: ResultType) -> bool { + match result { + Ok(is_match) => is_match, + Err(err) => { + log::warn!("Skipping invalid MSI uninstall entry: {err}"); + false + } + } +} + +pub(super) fn is_matching_entry( + entry: &RegKey, + app_name: &str, + key_name: &str, +) -> ResultType { + match entry.get_value::(REG_NAME_WINDOWS_INSTALLER) { + Ok(value) if value == MSI_WINDOWS_INSTALLER_VALUE => {} + Ok(_) => return Ok(false), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => bail!( + "Failed to read {REG_NAME_WINDOWS_INSTALLER} from MSI uninstall entry {key_name}: {err}" + ), + } + + match entry.get_value::(REG_NAME_DISPLAY_NAME) { + Ok(display_name) => Ok(display_name.eq_ignore_ascii_case(app_name)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => bail!( + "Failed to read {REG_NAME_DISPLAY_NAME} from MSI uninstall entry {key_name}: {err}" + ), + } +} diff --git a/src/plugin/callback_ext.rs b/src/plugin/callback_ext.rs deleted file mode 100644 index 715f47f7e..000000000 --- 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 2a23b03dd..000000000 --- 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 20cd02a88..000000000 --- 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 883f2afd7..000000000 --- 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 6b1e3612d..000000000 --- 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 6a14ab00a..000000000 --- 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 f59e4c9ff..000000000 --- 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 bd4b21b67..000000000 --- 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 ce885c77c..000000000 --- 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 82d7e10a6..000000000 --- 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 7d590ab1e..000000000 --- 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 3a3f62f8d..000000000 --- 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 aec7facd8..000000000 --- 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 f1e78d36e..000000000 --- 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 bf980ee8c..000000000 --- a/src/plugin/plugins.rs +++ /dev/null @@ -1,659 +0,0 @@ -use super::{desc::Desc, errno::*, *}; -#[cfg(not(debug_assertions))] -use crate::common::is_server; -use crate::flutter; -use hbb_common::{ - bail, - dlopen::symbor::Library, - lazy_static, log, - message_proto::{Message, Misc, PluginFailure, PluginRequest}, - ResultType, -}; -use serde_derive::Serialize; -use std::{ - collections::{HashMap, HashSet}, - ffi::{c_char, c_void}, - path::Path, - sync::{Arc, RwLock}, -}; - -pub const METHOD_HANDLE_STATUS: &[u8; 14] = b"handle_status\0"; -pub const METHOD_HANDLE_SIGNATURE_VERIFICATION: &[u8; 30] = b"handle_signature_verification\0"; -const METHOD_HANDLE_UI: &[u8; 10] = b"handle_ui\0"; -const METHOD_HANDLE_PEER: &[u8; 12] = b"handle_peer\0"; -pub const METHOD_HANDLE_LISTEN_EVENT: &[u8; 20] = b"handle_listen_event\0"; - -lazy_static::lazy_static! { - static ref PLUGIN_INFO: Arc>> = Default::default(); - static ref PLUGINS: Arc>> = Default::default(); -} - -pub(super) struct PluginInfo { - pub path: String, - pub uninstalled: bool, - pub desc: Desc, -} - -/// Initialize the plugins. -/// -/// data: The initialize data. -type PluginFuncInit = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Reset the plugin. -/// -/// data: The initialize data. -type PluginFuncReset = extern "C" fn(data: *const InitData) -> PluginReturn; -/// Clear the plugin. -type PluginFuncClear = extern "C" fn() -> PluginReturn; -/// Get the description of the plugin. -/// Return the description. The plugin allocate memory with `libc::malloc` and return the pointer. -type PluginFuncDesc = extern "C" fn() -> *const c_char; -/// Callback to send message to peer or ui. -/// peer, target, id are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// target: "peer" or "ui". -/// id: The id of this plugin. -/// content: The content. -/// len: The length of the content. -type CallbackMsg = extern "C" fn( - peer: *const c_char, - target: *const c_char, - id: *const c_char, - content: *const c_void, - len: usize, -) -> PluginReturn; -/// Callback to get the config. -/// peer, key are utf8 strings(null terminated). -/// -/// peer: The peer id. -/// id: The id of this plugin. -/// key: The key of the config. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetConf = - extern "C" fn(peer: *const c_char, id: *const c_char, key: *const c_char) -> *const c_char; -/// Get local peer id. -/// -/// The returned string is utf8 string(null terminated) and must be freed by caller. -type CallbackGetId = extern "C" fn() -> *const c_char; -/// Callback to log. -/// -/// level, msg are utf8 strings(null terminated). -/// level: "error", "warn", "info", "debug", "trace". -/// msg: The message. -type CallbackLog = extern "C" fn(level: *const c_char, msg: *const c_char); - -/// Callback to the librustdesk core. -/// -/// method: the method name of this callback. -/// json: the json data for the parameters. The argument *must* be non-null. -/// raw: the binary data for this call, nullable. -/// raw_len: the length of this binary data, only valid when we pass raw data to `raw`. -type CallbackNative = extern "C" fn( - method: *const c_char, - json: *const c_char, - raw: *const c_void, - raw_len: usize, -) -> super::native::NativeReturnValue; -/// The main function of the plugin. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -type PluginFuncCall = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, -) -> PluginReturn; -/// The main function of the plugin. -/// This function is called mainly for handling messages from the peer, -/// and then send messages back to the peer. -/// -/// method: The method. "handle_ui" or "handle_peer" -/// peer: The peer id. -/// args: The arguments. -/// len: The length of the arguments. -/// out: The output. -/// The plugin allocate memory with `libc::malloc` and return the pointer. -/// out_len: The length of the output. -type PluginFuncCallWithOutData = extern "C" fn( - method: *const c_char, - peer: *const c_char, - args: *const c_void, - len: usize, - out: *mut *mut c_void, - out_len: *mut usize, -) -> PluginReturn; - -/// The plugin callbacks. -/// msg: The callback to send message to peer or ui. -/// get_conf: The callback to get the config. -/// log: The callback to log. -#[repr(C)] -#[derive(Copy, Clone)] -struct Callbacks { - msg: CallbackMsg, - get_conf: CallbackGetConf, - get_id: CallbackGetId, - log: CallbackLog, - native: CallbackNative, -} - -#[derive(Serialize)] -#[repr(C)] -struct InitInfo { - is_server: bool, -} - -/// The plugin initialize data. -/// version: The version of the plugin, can't be nullptr. -/// local_peer_id: The local peer id, can't be nullptr. -/// cbs: The callbacks. -#[repr(C)] -struct InitData { - version: *const c_char, - info: *const c_char, - cbs: Callbacks, -} - -impl Drop for InitData { - fn drop(&mut self) { - free_c_ptr(self.version as _); - free_c_ptr(self.info as _); - } -} - -macro_rules! make_plugin { - ($($field:ident : $tp:ty),+) => { - #[allow(dead_code)] - pub struct Plugin { - _lib: Library, - id: Option, - path: String, - $($field: $tp),+ - } - - impl Plugin { - fn new(path: &str) -> ResultType { - let lib = match Library::open(path) { - Ok(lib) => lib, - Err(e) => { - bail!("Failed to load library {}, {}", path, e); - } - }; - - $(let $field = match unsafe { lib.symbol::<$tp>(stringify!($field)) } { - Ok(m) => { - *m - }, - Err(e) => { - bail!("Failed to load {} func {}, {}", path, stringify!($field), e); - } - } - ;)+ - - Ok(Self { - _lib: lib, - id: None, - path: path.to_string(), - $( $field ),+ - }) - } - - fn desc(&self) -> ResultType { - let desc_ret = (self.desc)(); - let desc = Desc::from_cstr(desc_ret); - free_c_ptr(desc_ret as _); - desc - } - - fn init(&self, data: &InitData, path: &str) -> ResultType<()> { - let mut init_ret = (self.init)(data as _); - if !init_ret.is_success() { - let (code, msg) = init_ret.get_code_msg(path); - bail!( - "Failed to init plugin {}, code: {}, msg: {}", - path, - code, - msg - ); - } - Ok(()) - } - - fn clear(&self, id: &str) { - let mut clear_ret = (self.clear)(); - if !clear_ret.is_success() { - let (code, msg) = clear_ret.get_code_msg(id); - log::error!( - "Failed to clear plugin {}, code: {}, msg: {}", - id, - code, - msg - ); - } - } - } - - impl Drop for Plugin { - fn drop(&mut self) { - let id = self.id.as_ref().unwrap_or(&self.path); - self.clear(id); - } - } - } -} - -make_plugin!( - init: PluginFuncInit, - reset: PluginFuncReset, - clear: PluginFuncClear, - desc: PluginFuncDesc, - call: PluginFuncCall, - call_with_out_data: PluginFuncCallWithOutData -); - -#[derive(Serialize)] -pub struct MsgListenEvent { - pub event: String, -} - -#[cfg(target_os = "windows")] -const DYLIB_SUFFIX: &str = ".dll"; -#[cfg(target_os = "linux")] -const DYLIB_SUFFIX: &str = ".so"; -#[cfg(target_os = "macos")] -const DYLIB_SUFFIX: &str = ".dylib"; - -pub(super) fn load_plugins(uninstalled_ids: &HashSet) -> ResultType<()> { - let plugins_dir = super::get_plugins_dir()?; - if !plugins_dir.exists() { - std::fs::create_dir_all(&plugins_dir)?; - } else { - for entry in std::fs::read_dir(plugins_dir)? { - match entry { - Ok(entry) => { - let plugin_dir = entry.path(); - if plugin_dir.is_dir() { - if let Some(plugin_id) = plugin_dir.file_name().and_then(|f| f.to_str()) { - if uninstalled_ids.contains(plugin_id) { - log::debug!( - "Ignore loading '{}' as it should be uninstalled", - plugin_id - ); - continue; - } - load_plugin_dir(&plugin_dir); - } - } - } - Err(e) => { - log::error!("Failed to read plugins dir entry, {}", e); - } - } - } - } - Ok(()) -} - -fn load_plugin_dir(dir: &Path) { - log::debug!("Begin load plugin dir: {}", dir.display()); - if let Ok(rd) = std::fs::read_dir(dir) { - for entry in rd { - match entry { - Ok(entry) => { - let path = entry.path(); - if path.is_file() { - let filename = entry.file_name(); - let filename = filename.to_str().unwrap_or(""); - if filename.starts_with("plugin_") && filename.ends_with(DYLIB_SUFFIX) { - if let Some(path) = path.to_str() { - if let Err(e) = load_plugin_path(path) { - log::error!("Failed to load plugin {}, {}", filename, e); - } - } - } - } - } - Err(e) => { - log::error!( - "Failed to read '{}' dir entry, {}", - dir.file_name().and_then(|f| f.to_str()).unwrap_or(""), - e - ); - } - } - } - } -} - -pub fn unload_plugin(id: &str) { - log::info!("Plugin {} unloaded", id); - PLUGINS.write().unwrap().remove(id); -} - -pub(super) fn mark_uninstalled(id: &str, uninstalled: bool) { - log::info!("Plugin {} uninstall", id); - PLUGIN_INFO - .write() - .unwrap() - .get_mut(id) - .map(|info| info.uninstalled = uninstalled); -} - -pub fn reload_plugin(id: &str) -> ResultType<()> { - let path = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => plugin.path.clone(), - None => bail!("Plugin {} not found", id), - }; - unload_plugin(id); - load_plugin_path(&path) -} - -fn load_plugin_path(path: &str) -> ResultType<()> { - log::info!("Begin load plugin {}", path); - - let plugin = Plugin::new(path)?; - let desc = plugin.desc()?; - - // to-do validate plugin - // to-do check the plugin id (make sure it does not use another plugin's id) - - let id = desc.meta().id.clone(); - let plugin_info = PluginInfo { - path: path.to_string(), - uninstalled: false, - desc: desc.clone(), - }; - PLUGIN_INFO.write().unwrap().insert(id.clone(), plugin_info); - - let init_info = serde_json::to_string(&InitInfo { - is_server: super::is_server_running(), - })?; - let init_data = InitData { - version: str_to_cstr_ret(crate::VERSION), - info: str_to_cstr_ret(&init_info) as _, - cbs: Callbacks { - msg: callback_msg::cb_msg, - get_conf: config::cb_get_conf, - get_id: config::cb_get_local_peer_id, - log: super::plog::plugin_log, - native: super::native::cb_native_data, - }, - }; - // If do not load the plugin when init failed, the ui will not show the installed plugin. - if let Err(e) = plugin.init(&init_data, path) { - log::error!("Failed to init plugin '{}', {}", desc.meta().id, e); - } - - if super::is_server_running() { - super::config::ManagerConfig::add_plugin(&desc.meta().id)?; - } - - // update ui - // Ui may be not ready now, so we need to update again once ui is ready. - reload_ui(&desc, None); - - // add plugins - PLUGINS.write().unwrap().insert(id.clone(), plugin); - - log::info!("Plugin {} loaded, {}", id, path); - Ok(()) -} - -pub fn sync_ui(sync_to: String) { - for plugin in PLUGIN_INFO.read().unwrap().values() { - reload_ui(&plugin.desc, Some(&sync_to)); - } -} - -#[inline] -pub fn load_plugin(id: &str) -> ResultType<()> { - load_plugin_dir(&super::get_plugin_dir(id)?); - Ok(()) -} - -#[inline] -fn handle_event(method: &[u8], id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - plugin_call(id, method, &peer, event) -} - -pub fn plugin_call(id: &str, method: &[u8], peer: &str, event: &[u8]) -> ResultType<()> { - let mut ret = plugin_call_get_return(id, method, peer, event)?; - if ret.is_success() { - Ok(()) - } else { - let (code, msg) = ret.get_code_msg(id); - bail!( - "Failed to handle plugin event, id: {}, method: {}, code: {}, msg: {}", - id, - std::string::String::from_utf8(method.to_vec()).unwrap_or_default(), - code, - msg - ); - } -} - -#[inline] -pub fn plugin_call_get_return( - id: &str, - method: &[u8], - peer: &str, - event: &[u8], -) -> ResultType { - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => Ok((plugin.call)( - method.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - )), - None => bail!("Plugin {} not found", id), - } -} - -#[inline] -pub fn handle_ui_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_UI, id, peer, event) -} - -#[inline] -pub fn handle_server_event(id: &str, peer: &str, event: &[u8]) -> ResultType<()> { - handle_event(METHOD_HANDLE_PEER, id, peer, event) -} - -fn _handle_listen_event(event: String, peer: String) { - let mut plugins = Vec::new(); - for info in PLUGIN_INFO.read().unwrap().values() { - if info.desc.listen_events().contains(&event.to_string()) { - plugins.push(info.desc.meta().id.clone()); - } - } - - if plugins.is_empty() { - return; - } - - if let Ok(evt) = serde_json::to_string(&MsgListenEvent { - event: event.clone(), - }) { - let mut evt_bytes = evt.as_bytes().to_vec(); - evt_bytes.push(0); - let mut peer: String = peer.to_owned(); - peer.push('\0'); - for id in plugins { - match PLUGINS.read().unwrap().get(&id) { - Some(plugin) => { - let mut ret = (plugin.call)( - METHOD_HANDLE_LISTEN_EVENT.as_ptr() as _, - peer.as_ptr() as _, - evt_bytes.as_ptr() as _, - evt_bytes.len(), - ); - if !ret.is_success() { - let (code, msg) = ret.get_code_msg(&id); - log::error!( - "Failed to handle plugin listen event, id: {}, event: {}, code: {}, msg: {}", - id, - event, - code, - msg - ); - } - } - None => { - log::error!("Plugin {} not found when handle_listen_event", id); - } - } - } - } -} - -#[inline] -pub fn handle_listen_event(event: String, peer: String) { - std::thread::spawn(|| _handle_listen_event(event, peer)); -} - -#[inline] -pub fn handle_client_event(id: &str, peer: &str, event: &[u8]) -> Message { - let mut peer: String = peer.to_owned(); - peer.push('\0'); - match PLUGINS.read().unwrap().get(id) { - Some(plugin) => { - let mut out = std::ptr::null_mut(); - let mut out_len: usize = 0; - let mut ret = (plugin.call_with_out_data)( - METHOD_HANDLE_PEER.as_ptr() as _, - peer.as_ptr() as _, - event.as_ptr() as _, - event.len(), - &mut out as _, - &mut out_len as _, - ); - if ret.is_success() { - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } else { - let (code, msg) = ret.get_code_msg(id); - if code > ERR_RUSTDESK_HANDLE_BASE && code < ERR_PLUGIN_HANDLE_BASE { - log::debug!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let name = match PLUGIN_INFO.read().unwrap().get(id) { - Some(plugin) => &plugin.desc.meta().name, - None => "???", - } - .to_owned(); - match code { - ERR_CALL_NOT_SUPPORTED_METHOD => { - make_plugin_failure(id, &name, "Plugin method is not supported") - } - ERR_CALL_INVALID_ARGS => { - make_plugin_failure(id, &name, "Plugin arguments is invalid") - } - _ => make_plugin_failure(id, &name, &msg), - } - } else { - log::error!( - "Plugin {} failed to handle client event, code: {}, msg: {}", - id, - code, - msg - ); - let msg = make_plugin_request(id, out, out_len); - free_c_ptr(out as _); - msg - } - } - } - None => make_plugin_failure(id, "", "Plugin not found"), - } -} - -fn make_plugin_request(id: &str, content: *const c_void, len: usize) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_request(PluginRequest { - id: id.to_owned(), - content: unsafe { std::slice::from_raw_parts(content as *const u8, len) } - .clone() - .into(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn make_plugin_failure(id: &str, name: &str, msg: &str) -> Message { - let mut misc = Misc::new(); - misc.set_plugin_failure(PluginFailure { - id: id.to_owned(), - name: name.to_owned(), - msg: msg.to_owned(), - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_misc(misc); - msg_out -} - -fn reload_ui(desc: &Desc, sync_to: Option<&str>) { - for (location, ui) in desc.location().ui.iter() { - if let Ok(ui) = serde_json::to_string(&ui) { - let make_event = |ui: &str| { - let mut m = HashMap::new(); - m.insert("name", MSG_TO_UI_TYPE_PLUGIN_RELOAD); - m.insert("id", &desc.meta().id); - m.insert("location", &location); - // Do not depend on the "location" and plugin desc on the ui side. - // Send the ui field to ensure the ui is valid. - m.insert("ui", ui); - serde_json::to_string(&m).unwrap_or("".to_owned()) - }; - match sync_to { - Some(channel) => { - let _res = flutter::push_global_event(channel, make_event(&ui)); - } - None => { - let v: Vec<&str> = location.split('|').collect(); - // The first element is the "client" or "host". - // The second element is the "main", "remote", "cm", "file transfer", "port forward". - if v.len() >= 2 { - let available_channels = flutter::get_global_event_channels(); - if available_channels.contains(&v[1]) { - let _res = flutter::push_global_event(v[1], make_event(&ui)); - } - } - } - } - } - } -} - -pub(super) fn get_plugin_infos() -> Arc>> { - PLUGIN_INFO.clone() -} - -pub(super) fn get_desc_conf(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.config().clone()) -} - -pub(super) fn get_version(id: &str) -> Option { - PLUGIN_INFO - .read() - .unwrap() - .get(id) - .map(|info| info.desc.meta().version.clone()) -} diff --git a/src/port_forward.rs b/src/port_forward.rs index 8b190fb1e..392ed3c67 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -15,7 +15,7 @@ use hbb_common::{ ResultType, Stream, }; -fn run_rdp(port: u16) { +fn run_rdp(port: u16, name: &str) { std::process::Command::new("cmdkey") .arg("/delete:localhost") .output() @@ -35,10 +35,37 @@ fn run_rdp(port: u16) { .output() .ok(); } - std::process::Command::new("mstsc") + // Keep using /v instead of a generated .rdp file: mstsc then preserves the + // user's Default.rdp settings and avoids unsigned-file warnings or policies. + match std::process::Command::new("mstsc") .arg(format!("/v:localhost:{}", port)) .spawn() - .ok(); + { + Ok(child) => { + #[cfg(windows)] + crate::platform::set_rdp_window_title(child, name.to_owned()); + #[cfg(not(windows))] + let _ = (child, name); + } + Err(err) => log::warn!("Failed to launch mstsc: {}", err), + } +} + +// Show the peer identity with its hostname, using the ID when no alias exists. +fn rdp_display_name(lc: &Arc>, id: &str) -> String { + let lc = lc.read().unwrap(); + let alias = lc + .options + .get("alias") + .map(|s| s.trim()) + .unwrap_or_default(); + let hostname = lc.info.hostname.trim(); + let identity = if !alias.is_empty() { alias } else { id }; + if hostname.is_empty() || hostname == identity { + identity.to_owned() + } else { + format!("{} ({})", identity, hostname) + } } pub async fn listen( @@ -58,7 +85,7 @@ pub async fn listen( log::info!("listening on port {:?}", addr); let is_rdp = port == 0; if is_rdp { - run_rdp(addr.port()); + run_rdp(addr.port(), &rdp_display_name(&lc, &id)); } let mut ui_receiver = ui_receiver; loop { @@ -96,7 +123,7 @@ pub async fn listen( } Some(Data::NewRDP) => { println!("receive run_rdp from ui_receiver"); - run_rdp(addr.port()); + run_rdp(addr.port(), &rdp_display_name(&lc, &id)); } _ => {} } @@ -150,7 +177,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/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 4f5c8fee8..21a7e23f4 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -143,11 +143,6 @@ impl RendezvousMediator { allow_err!(super::lan::start_listening()); }); } - // It is ok to run xdesktop manager when the headless function is not allowed. - #[cfg(target_os = "linux")] - if crate::is_server() { - crate::platform::linux_desktop_manager::start_xdesktop(); - } scrap::codec::test_av1(); *LAST_NOT_DEPLOYED_REGISTER.lock().await = None; loop { diff --git a/src/server.rs b/src/server.rs index f02a15a7f..5af982772 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/connection.rs b/src/server/connection.rs index 4dc07d366..bcdae795a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -12,8 +12,6 @@ use crate::clipboard::{update_clipboard, ClipboardSide}; use crate::clipboard_file::*; #[cfg(target_os = "android")] use crate::keyboard::client::map_key_to_control_key; -#[cfg(target_os = "linux")] -use crate::platform::linux_desktop_manager; #[cfg(any(target_os = "windows", target_os = "linux"))] use crate::platform::WallPaperRemover; #[cfg(windows)] @@ -91,11 +89,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")] @@ -113,27 +115,6 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { x == 0 } -#[cfg(target_os = "linux")] -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() -} - -#[cfg(target_os = "linux")] -fn should_record_linux_headless_os_auth_failure( - is_headless_allowed: bool, - username: &str, - err_msg: &str, -) -> bool { - is_headless_allowed - && !username.trim().is_empty() - && err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG -} - #[cfg(not(any(target_os = "android", target_os = "ios")))] fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool { cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty() @@ -147,43 +128,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, @@ -209,12 +153,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)] @@ -235,8 +173,6 @@ struct Session { struct StartCmIpcPara { rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, - rx_desktop_ready: mpsc::Receiver<()>, - tx_cm_stream_ready: mpsc::Sender<()>, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -363,6 +299,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, @@ -375,8 +314,6 @@ pub struct Connection { options_in_login: Option, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: HashSet, - #[cfg(target_os = "linux")] - linux_headless_handle: LinuxHeadlessHandle, closed: bool, #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Option, @@ -457,6 +394,20 @@ 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. +/// +/// A cold cache probes off-thread; admission still requires a definitive `Available` verdict. +#[cfg(all(target_os = "linux", feature = "drm"))] +fn drm_can_serve_login_screen() -> bool { + super::drm_capturer::availability_cached() == super::drm_capturer::Availability::Available +} + +/// 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, @@ -490,14 +441,6 @@ impl Connection { let (tx_input, _rx_input) = std_mpsc::channel(); let (tx_from_authed, mut rx_from_authed) = mpsc::unbounded_channel::(); let mut hbbs_rx = crate::hbbs_http::sync::signal_receiver(); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - let (tx_cm_stream_ready, _rx_cm_stream_ready) = mpsc::channel(1); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - let (_tx_desktop_ready, rx_desktop_ready) = mpsc::channel(1); - #[cfg(target_os = "linux")] - let linux_headless_handle = - LinuxHeadlessHandle::new(_rx_cm_stream_ready, _tx_desktop_ready); - let (tx_post_seq, rx_post_seq) = mpsc::unbounded_channel(); tokio::spawn(async move { Self::post_seq_loop(rx_post_seq).await; @@ -560,6 +503,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, @@ -573,15 +517,11 @@ impl Connection { options_in_login: None, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: Default::default(), - #[cfg(target_os = "linux")] - linux_headless_handle, closed: false, #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Some(StartCmIpcPara { rx_to_cm, tx_from_cm, - rx_desktop_ready, - tx_cm_stream_ready, }), auto_disconnect_timer: None, authed_conn_id: None, @@ -702,6 +642,18 @@ impl Connection { conn.on_close("connection manager", true).await; break; } + // The connection manager's window went away rather than a person + // disconnecting this peer. End the session exactly as above, but do not + // send the manual close reason: it is the one thing that stops the peer + // from retrying, and on a logout the retry is the whole point - it is + // what puts the peer back on the login screen a moment later. + #[cfg(target_os = "linux")] + ipc::Data::CmWindowClosed => { + conn.chat_unanswered = false; // seen + conn.file_transferred = false; //seen + conn.on_close("connection manager window closed", true).await; + break; + } ipc::Data::CmErr(e) => { if e != "expected" { // cm closed before connection @@ -1138,12 +1090,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(); @@ -1228,35 +1174,8 @@ impl Connection { ); } } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOnPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(true); - if ok { - block_input_mode = true; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } - #[cfg(all(feature = "flutter", feature = "plugin_framework"))] - #[cfg(not(any(target_os = "android", target_os = "ios")))] - MessageInput::BlockOffPlugin(_peer) => { - let (ok, _msg) = crate::platform::block_input(false); - if ok { - block_input_mode = false; - } - let _r = PLUGIN_BLOCK_INPUT_TX_RX - .0 - .lock() - .unwrap() - .send(block_input_mode); - } }, Err(err) => { - #[cfg(not(any(target_os = "android", target_os = "ios")))] if block_input_mode { let _ = crate::platform::block_input(true); } @@ -1294,6 +1213,13 @@ impl Connection { ipc::Data::Close => { bail!("Close requested from connection manager"); } + // Same end as above: a tunnel must not outlive the window either. + // Only the reason differs, and a port forward carries none - the + // peer sees the tunnel drop and decides for itself. + #[cfg(target_os = "linux")] + ipc::Data::CmWindowClosed => { + bail!("Connection manager window closed"); + } ipc::Data::CmErr(e) => { log::error!("Connection manager error: {e}"); bail!("{e}"); @@ -1506,6 +1432,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 +1483,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 +1505,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 +1533,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) { @@ -1794,12 +1818,6 @@ impl Connection { if crate::platform::current_is_wayland() { platform_additions.insert("is_wayland".into(), json!(true)); } - #[cfg(target_os = "linux")] - if crate::platform::is_headless_allowed() { - if linux_desktop_manager::is_headless() { - platform_additions.insert("headless".into(), json!(true)); - } - } } #[cfg(target_os = "windows")] { @@ -1861,7 +1879,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(); @@ -1895,13 +1914,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"))); @@ -2009,11 +2021,17 @@ impl Connection { 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 { - "" - }; + let is_existing_dir = !dir.is_empty() && std::path::Path::new(&dir).is_dir(); + let is_allowed_dir = + is_existing_dir && crate::common::is_peer_path_allowed(&dir, false); + #[cfg(target_os = "android")] + if is_existing_dir && !is_allowed_dir { + log::warn!( + "Use the app workspace because the initial file-transfer directory is outside it: {}", + dir + ); + } + let dir = if is_allowed_dir { &dir } else { "" }; if !wait_session_id_confirm { self.read_dir(dir, show_hidden); } else { @@ -2523,6 +2541,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); @@ -2552,14 +2654,7 @@ impl Connection { tokio::spawn(async move { #[cfg(windows)] let tx_from_cm_clone = p.tx_from_cm.clone(); - if let Err(err) = start_ipc( - p.rx_to_cm, - p.tx_from_cm, - p.rx_desktop_ready, - p.tx_cm_stream_ready, - ) - .await - { + if let Err(err) = start_ipc(p.rx_to_cm, p.tx_from_cm).await { log::warn!("ipc to connection manager exit: {}", err); // https://github.com/rustdesk/rustdesk-server-pro/discussions/382#discussioncomment-10525725, cm may start failed #[cfg(windows)] @@ -2600,6 +2695,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 { @@ -2693,43 +2791,6 @@ impl Connection { self.try_start_cm_ipc(); } - #[cfg(target_os = "linux")] - if should_check_linux_headless_os_auth_before_desktop_start( - self.linux_headless_handle.is_headless_allowed, - &lr.os_login.username, - ) { - let (_failure, res) = self.check_failure(0).await; - if !res { - return true; - } - } - - #[cfg(not(target_os = "linux"))] - let err_msg = "".to_owned(); - #[cfg(target_os = "linux")] - let err_msg = self - .linux_headless_handle - .try_start_desktop(lr.os_login.as_ref()); - - // 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 - { - #[cfg(target_os = "linux")] - if should_record_linux_headless_os_auth_failure( - self.linux_headless_handle.is_headless_allowed, - &lr.os_login.username, - &err_msg, - ) { - let (failure, res) = self.check_failure(0).await; - if !res { - return true; - } - self.update_failure(failure, false, 0); - } - self.send_login_error(err_msg).await; - return true; - } - // 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. @@ -2773,33 +2834,19 @@ impl Connection { } return true; } else if self.is_recent_session(false) { - if err_msg.is_empty() { - #[cfg(target_os = "linux")] - self.linux_headless_handle.wait_desktop_cm_ready().await; - if !self.send_logon_response_and_keep_alive().await { - return false; - } - self.try_start_cm(lr.my_id.clone(), lr.my_name.clone(), self.authorized); - } else { - self.send_login_error(err_msg).await; + if !self.send_logon_response_and_keep_alive().await { + return false; } + self.try_start_cm(lr.my_id.clone(), lr.my_name.clone(), self.authorized); } else if lr.password.is_empty() { - if err_msg.is_empty() { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { - if let Some(keep_alive) = - self.prepare_terminal_login_for_authorization().await - { - return keep_alive; - } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await + { + return keep_alive; } - self.try_start_cm(lr.my_id, lr.my_name, false); - } else { - self.send_login_error( - crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_EMPTY, - ) - .await; } + self.try_start_cm(lr.my_id, lr.my_name, false); } else { let (failure, res) = self.check_failure(0).await; if !res { @@ -2808,28 +2855,15 @@ impl Connection { if !self.validate_password(allow_logon_screen_password) { self.update_failure_with_scope(failure, false, 0, FailureScope::Default); self.check_update_temporary_password(false); - if err_msg.is_empty() { - self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) - .await; - self.try_start_cm(lr.my_id, lr.my_name, false); - } else { - self.send_login_error( - crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY_PASSWORD_WRONG, - ) + self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) .await; - } + self.try_start_cm(lr.my_id, lr.my_name, false); } else { self.update_failure_with_scope(failure, true, 0, FailureScope::Default); - if err_msg.is_empty() { - #[cfg(target_os = "linux")] - self.linux_headless_handle.wait_desktop_cm_ready().await; - if !self.send_logon_response_and_keep_alive().await { - return false; - } - self.try_start_cm(lr.my_id, lr.my_name, self.authorized); - } else { - self.send_login_error(err_msg).await; + if !self.send_logon_response_and_keep_alive().await { + return false; } + self.try_start_cm(lr.my_id, lr.my_name, self.authorized); } } } else if let Some(message::Union::Auth2fa(tfa)) = msg.union { @@ -2896,7 +2930,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 { @@ -3285,6 +3319,81 @@ impl Connection { return true; } } + // Android is scoped-storage only: reject any peer supplied path that + // escapes the app workspace before it reaches the filesystem. + #[cfg(target_os = "android")] + { + // (path, job id, allow empty) of the peer supplied path this action + // operates on. + let checked: Option<(&str, i32, bool)> = match &fa.union { + Some(file_action::Union::ReadEmptyDirs(rd)) => { + Some((rd.path.as_str(), -1, false)) + } + Some(file_action::Union::ReadDir(rd)) => { + Some((rd.path.as_str(), 0, true)) + } + Some(file_action::Union::AllFiles(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Send(s)) => { + // Printer jobs read from memory, `path` is only a lookup key. + if JobType::from_proto(s.file_type) == JobType::Generic { + Some((s.path.as_str(), s.id, false)) + } else { + None + } + } + Some(file_action::Union::Receive(r)) => { + Some((r.path.as_str(), r.id, false)) + } + Some(file_action::Union::RemoveDir(d)) => { + Some((d.path.as_str(), d.id, false)) + } + Some(file_action::Union::RemoveFile(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Create(c)) => { + Some((c.path.as_str(), c.id, false)) + } + Some(file_action::Union::Rename(r)) => { + Some((r.path.as_str(), r.id, false)) + } + _ => None, + }; + if let Some((path, job_id, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!( + "Reject file action outside the app workspace: {}", + path + ); + if job_id >= 0 { + self.send(fs::new_error(job_id, "Permission denied", -1)) + .await; + } + return true; + } + } + if let Some(file_action::Union::Rename(r)) = &fa.union { + let destination = std::path::Path::new(&r.path) + .parent() + .map(|parent| parent.join(&r.new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + self.send(fs::new_error(r.id, "Permission denied", -1)) + .await; + return true; + } + } + } match fa.union { Some(file_action::Union::ReadEmptyDirs(rd)) => { self.read_empty_dirs(&rd.path, rd.include_hidden); @@ -3636,17 +3745,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; } @@ -3663,13 +3773,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() @@ -4859,7 +4962,7 @@ impl Connection { } } else { crate::common::make_privacy_mode_msg( - back_notification::PrivacyModeState::PrvOnFailedPlugin, + back_notification::PrivacyModeState::PrvOnFailed, impl_key, ) } @@ -5950,34 +6053,48 @@ 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")))] // IPC bootstrap summary: -// - Resolve target CM socket (headless/non-headless, optional UID-scoped path on Linux). // - Start CM when missing, then bridge bidirectional messages between this task and CM IPC. async fn start_ipc( mut rx_to_cm: mpsc::UnboundedReceiver, tx_from_cm: mpsc::UnboundedSender, - mut _rx_desktop_ready: mpsc::Receiver<()>, - tx_stream_ready: mpsc::Sender<()>, ) -> ResultType<()> { use hbb_common::anyhow::anyhow; @@ -5987,139 +6104,51 @@ async fn start_ipc( } sleep(1.).await; } - #[cfg(target_os = "linux")] - let headless_cm = crate::is_server() - && crate::platform::is_headless_allowed() - && linux_desktop_manager::is_headless(); - #[cfg(not(target_os = "linux"))] - let headless_cm = false; let mut stream = None; - if !headless_cm { - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); - } + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); } if stream.is_none() { - #[allow(unused_mut)] - #[allow(unused_assignments)] - let mut args = vec!["--cm"]; - #[allow(unused_mut)] - #[cfg(target_os = "linux")] - let mut user = None; - - // 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(); - loop { - if !username.is_empty() { - break; + let args = vec!["--cm"]; + let run_done; + if crate::platform::is_root() { + let mut res = Ok(None); + for _ in 0..10 { + #[cfg(not(any(target_os = "linux")))] + { + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone()); } - // `_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 uid = { - let username_for_cmd = username.clone(); - let mut uid_cmd = hbb_common::tokio::process::Command::new("id"); - // TODO: - // Keep current behavior for now to minimize change risk. - // If usernames starting with '-' are observed in the field, prefer: - // `id -u -- ` to avoid option-parsing ambiguity. - // Already verified that `id -u -- ` works as expected on macOS and Ubuntu 24.04. - uid_cmd.arg("-u").arg(&username_for_cmd).kill_on_drop(true); - let output = timeout(10_000, uid_cmd.output()) - .await - .map_err(|_| anyhow!("Timed out querying uid for {}", username))? - .map_err(|e| anyhow!("Failed to run `id -u {}`: {}", username, e))?; - if !output.status.success() { - bail!("Failed to query uid for {}", username); - } - let output = String::from_utf8_lossy(&output.stdout); - let output = output.trim(); - if output.parse::().is_err() { - bail!("Invalid uid {}", output); - } - output.to_string() - }; - user = Some((uid, username)); - args = vec!["--cm-no-ui"]; - } - #[cfg(target_os = "linux")] - let cm_uid: Option = match &user { - Some((uid, _)) => Some( - uid.parse::() - .map_err(|_| anyhow!("Invalid uid {}", uid))?, - ), - None => None, - }; - #[cfg(target_os = "linux")] - if let Some(uid) = cm_uid { - if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { - stream = Some(s); - } - } - if stream.is_none() { - let run_done; - if crate::platform::is_root() { - let mut res = Ok(None); - for _ in 0..10 { - #[cfg(not(any(target_os = "linux")))] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user(args.clone()); - } - #[cfg(target_os = "linux")] - { - log::debug!("Start cm"); - res = crate::platform::run_as_user( - args.clone(), - user.clone(), - None::<(&str, &str)>, - ); - } - if res.is_ok() { - break; - } - log::error!("Failed to run cm: {res:?}"); - sleep(1.).await; - } - if let Some(task) = res? { - super::CHILD_PROCESS.lock().unwrap().push(task); - } - run_done = true; - } else { - run_done = false; - } - if !run_done { - log::debug!("Start cm"); - super::CHILD_PROCESS - .lock() - .unwrap() - .push(crate::run_me(args)?); - } - for _ in 0..20 { - sleep(0.3).await; #[cfg(target_os = "linux")] { - if let Some(uid) = cm_uid { - if let Ok(s) = crate::ipc::connect_for_uid(1000, uid, "_cm").await { - stream = Some(s); - break; - } - continue; - } + log::debug!("Start cm"); + res = crate::platform::run_as_user(args.clone(), None, None::<(&str, &str)>); } - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); + if res.is_ok() { break; } + log::error!("Failed to run cm: {res:?}"); + sleep(1.).await; + } + if let Some(task) = res? { + super::CHILD_PROCESS.lock().unwrap().push(task); + } + run_done = true; + } else { + run_done = false; + } + if !run_done { + log::debug!("Start cm"); + super::CHILD_PROCESS + .lock() + .unwrap() + .push(crate::run_me(args)?); + } + for _ in 0..20 { + sleep(0.3).await; + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + break; } } } @@ -6127,7 +6156,6 @@ async fn start_ipc( bail!("Failed to connect to connection manager"); } - let _res = tx_stream_ready.send(()).await; let mut stream = stream.ok_or(anyhow!("none stream"))?; loop { tokio::select! { @@ -6441,50 +6469,6 @@ impl Drop for Connection { } } -#[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<()>, -} - -#[cfg(target_os = "linux")] -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 wait_desktop_cm_ready(&mut self) { - if self.is_headless { - self.tx_desktop_ready.send(()).await.ok(); - let _res = timeout(self.wait_ipc_timeout, self.rx_cm_stream_ready.recv()).await; - } - } -} - extern "C" fn connection_shutdown_hook() { // https://stackoverflow.com/questions/35980148/why-does-an-atexit-handler-panic-when-it-accesses-stdout // Please make sure there is no print in the call stack @@ -6905,6 +6889,79 @@ mod test { #[allow(unused)] use super::*; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[test] + fn test_pending_switch_sides_uuid_is_claimed_once() { + let id = uuid::Uuid::new_v4().to_string(); + let uuid = uuid::Uuid::new_v4(); + let other_uuid = uuid::Uuid::new_v4(); + assert!(insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + + assert!(!insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + assert!(has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(!claim_pending_switch_sides_uuid("other-peer", &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!insert_pending_switch_sides_uuid(id, uuid)); + } + + #[test] + fn login_scope_latches_session_scope_across_login_retries() { + let port_forward = |host: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_port_forward(PortForward { + host: host.to_owned(), + port: 3389, + ..Default::default() + }); + lr + }; + let first = port_forward("localhost"); + let scope = |lr: &LoginRequest| Connection::login_scope_digest(lr); + + // A retry may carry new credentials, profile data, options, and unknown fields. + let mut retry = port_forward("localhost"); + retry.password = "secret".into(); + retry.hwid = "hwid".into(); + retry.os_login = Some(OSLogin { + username: "admin".to_owned(), + ..Default::default() + }) + .into(); + retry.my_name = "New Display Name".to_owned(); + retry.avatar = "data:image/png;base64,AAAA".to_owned(); + retry + .special_fields + .mut_unknown_fields() + .add_varint(9999, 1); + assert_eq!(scope(&first), scope(&retry)); + + // It may not change the controller identity, move the target, or switch type. + let mut rotated_id = first.clone(); + rotated_id.my_id = "rotated-id".to_owned(); + assert_ne!(scope(&first), scope(&rotated_id)); + assert_ne!(scope(&first), scope(&port_forward("10.0.0.5"))); + let mut moved_port = port_forward("localhost"); + moved_port.mut_port_forward().port = 22; + assert_ne!(scope(&first), scope(&moved_port)); + let terminal = |service_id: &str| { + let mut lr = LoginRequest::new(); + lr.my_id = "peer".to_owned(); + lr.set_terminal(Terminal { + service_id: service_id.to_owned(), + ..Default::default() + }); + lr + }; + assert_ne!(scope(&first), scope(&terminal(""))); + assert_ne!(scope(&terminal("a")), scope(&terminal("b"))); + } + #[test] fn test_wildcard_match() { // Exact match. diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 8531076a9..235c7ca86 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); @@ -93,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 { @@ -328,6 +340,29 @@ 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() { + let synced = !SYNC_DISPLAYS.lock().unwrap().displays.is_empty(); + let stamped_before = scrap::wayland::display::wayland_failure_stamped(); + // With nothing published yet, even the unaugmented DRM list beats the empty + // broadcast below; with a synced layout, a suppressed turn keeps it instead. + if !synced || !scrap::wayland::display::wayland_lookup_suppressed() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + // A first failure keeps the synced layout for one backoff; only a + // failure that persists across one replaces it with the DRM stack. + if !synced + || stamped_before + || !scrap::wayland::display::wayland_lookup_suppressed() + { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } + } + } return get_displays_msg(); } } @@ -434,12 +469,55 @@ pub(super) fn get_display_info(idx: usize) -> Option { SYNC_DISPLAYS.lock().unwrap().displays.get(idx).cloned() } +// True when at least one advertised (synced) display is NOT served by the DRM/KMS capture path, +// i.e. a mixed DRM + PipeWire session. The cursor service (platform::linux::get_cursor / +// get_cursor_data) uses this to decide whether a hidden DRM hardware-cursor sentinel is +// authoritative: in a pure-DRM session it is (the pointer is genuinely off every captured CRTC), +// but in a mixed session the sentinel only means the pointer moved onto a PipeWire-served display, +// whose cursor must come from the normal path instead of being hidden everywhere. +// +// When DRM capture is active the advertised list is enumerated from the DRM display list, so a DRM +// list shorter than the synced list means at least one advertised display is served by PipeWire. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub fn has_non_drm_backed_display() -> bool { + match super::drm_capturer::display_count_and_any_demoted() { + // A display served by PipeWire is either ABSENT from the DRM list (a shorter count, e.g. a + // pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked + // offline so the index space stays aligned -- see get_display_infos). The count check alone + // misses the demotion case (same count), so a demoted display is treated as non-DRM-backed + // too. This is what gates the hidden-cursor sentinel: it stays authoritative only in a + // pure-DRM session. The scalar accessor is deliberate: this is polled every cursor tick + // while the sentinel is active, and cloning + geometry-augmenting the whole list per tick + // (what get_display_infos does) answered the same two facts. + Some((count, any_demoted)) => { + count < SYNC_DISPLAYS.lock().unwrap().displays.len() || any_demoted + } + None => false, + } +} + // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { let _ = update_sync_displays(all); } +/// Whether there is a compositor on this seat worth asking. `get_displays()` does not cache +/// its failure, so where there is none it re-probes every call for an answer that cannot +/// change any caller's outcome. Last in the `&&` chain, so it never runs first on a poll. +#[inline] +#[cfg(target_os = "linux")] +fn wayland_has_compositor() -> bool { + #[cfg(feature = "drm")] + { + !crate::platform::linux::is_login_screen_wayland_cached() + } + #[cfg(not(feature = "drm"))] + { + true + } +} + // Return the converted input snapshot while updating the shared display cache. pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. @@ -447,6 +525,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 new file mode 100644 index 000000000..fb7719b80 --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,1848 @@ +// 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 or blocks. Use in hot paths such as `wayland::clear()`, `is_inited()`, and display +/// enumeration, where seconds of IPC would trip "deadline has elapsed". +pub(crate) fn is_available_cached() -> bool { + matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) +} + +/// A tri-state assessment of DRM capture availability. +/// `Unsettled` means a probe is in flight or failures have not reached the disable threshold. +#[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(); + // Keep a settled "no" while an off-thread probe re-verifies it, avoiding a transient + // Unsettled result whenever the negative cache expires. + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, // fall through and probe with the lock released + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + // Someone else is mid-probe: their result is not in yet, and "not yet" is not "no". + return match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(..) => Availability::Available, + ProbeState::Unavailable(_) => Availability::Unavailable, + ProbeState::Unknown => Availability::Unsettled, + }; + } + let _in_flight = ProbeInFlightGuard; + probe_and_publish() +} + +/// Non-blocking login-path assessment. +/// Unknown starts a probe off-thread; callers require `Available` before admitting a session. +pub(crate) fn availability_cached() -> Availability { + let (verdict, stale_no) = { + let st = DRM_STATE.lock().unwrap(); + let stale_no = + matches!(&*st, ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL); + let verdict = match &*st { + ProbeState::Available(since, _) => { + Some((Availability::Available, since.elapsed() >= POSITIVE_TTL)) + } + ProbeState::Unavailable(_) => Some((Availability::Unavailable, false)), + ProbeState::Unknown => None, + }; + (verdict, stale_no) + }; + if let Some((answer, stale)) = verdict { + if stale { + refresh_available_async(); + } + if stale_no { + refresh_unavailable_async(); + } + return answer; + } + if !DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + let in_flight = ProbeInFlightGuard; + let spawned = std::thread::Builder::new() + .name("drm-avail-probe".into()) + .spawn(move || { + let _in_flight = in_flight; + probe_and_publish(); + }); + // On error the guard moved into the dropped closure and released the flag already. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the availability probe thread: {err}"); + } + } + Availability::Unsettled +} + +/// Probe synchronously and publish the outcome. The caller must hold DRM_PROBE_IN_FLIGHT. +fn probe_and_publish() -> Availability { + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let answer = match result { + Ok(list) if !list.is_empty() => { + log::debug!( + "drm: availability probe -> available ({} displays) in {:?}", + list.len(), + t.elapsed() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + Availability::Available + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable + } + Err(err) => { + let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if n >= DRM_PROBE_MAX_FAILURES { + log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + Availability::Unavailable + } else { + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + // Deliberately still Unknown in DRM_STATE: this is a retry window, not a verdict. + Availability::Unsettled + } + } + }; + drop(st); + answer +} + +/// The boolean form for capture-path callers, where an unsettled probe and a definitive "no" +/// route the same way (into the non-DRM fallback). +pub(crate) fn is_available() -> bool { + availability() == Availability::Available +} + +/// The negative mirror of `refresh_available_async`: re-verify a stale Unavailable without ever +/// answering Unknown in the meantime. A failed or empty re-probe re-confirms the "no" with a +/// fresh timestamp; only a non-empty display list flips the verdict. +fn refresh_unavailable_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + match &*st { + ProbeState::Unavailable(since) if since.elapsed() >= NEGATIVE_TTL => {} + _ => return, + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-unavail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + match result { + Ok(list) if !list.is_empty() => { + log::info!( + "drm: availability re-probe -> available ({} displays)", + list.len() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + _ => { + // Restamp: a failed or empty re-probe is a fresh confirmation of "no". + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + } + }); + // Nothing to release on error: the guard moved into the closure and drops with it either way. + if let Err(err) = spawned { + log::warn!("drm: could not spawn the unavailability re-probe thread: {err}"); + } +} + +fn refresh_available_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-avail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + let failures = match &result { + Ok(_) => { + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + 0 + } + Err(_) => DRM_REFRESH_FAILURES.fetch_add(1, Ordering::Relaxed) + 1, + }; + match refresh_outcome(result.as_ref().ok().map(|l| l.len()), failures) { + RefreshOutcome::Publish => { + let fresh = result.unwrap_or_default(); + let changed = match &*st { + ProbeState::Available(_, old) => *old != fresh, + _ => true, + }; + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), fresh)); + if changed { + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + RefreshOutcome::Unavailable => { + log::info!("drm: refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + // Only the TTL stamp moves, so this does NOT go through publish_probe_state. + RefreshOutcome::Restamp => { + if let ProbeState::Available(since, _) = &mut *st { + *since = Instant::now(); + } + } + RefreshOutcome::GiveUp => { + log::info!( + "drm: availability refresh failed {failures}x ({:?}); the producer looks \ + gone, dropping the cached verdict so the next enumeration re-probes", + result.as_ref().err() + ); + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Unknown); + } + } + }); + // Nothing to release: the guard moved into the closure and drops with it. Clearing the flag + // explicitly would let TWO PROBES RUN AT ONCE, since another refresh may already hold it. + if let Err(err) = spawned { + log::warn!( + "drm: could not spawn the availability refresh thread: {err}; the cached verdict \ + stays stale until the next probe" + ); + } +} + +pub(super) fn warm_availability() { + // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl + // cannot yet name the seat0 session. `is_x11_for_drm()` is that form minus the greeter + // blind spot, where plain `is_x11()` is permanently true. + for _ in 0..10 { + if crate::platform::linux::is_x11_for_drm() { + std::thread::sleep(Duration::from_millis(300)); + continue; + } + if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) { + return; + } + match query_displays() { + Ok(list) if !list.is_empty() => { + log::info!("drm: consumer cache warmed ({} displays) at startup", list.len()); + publish_probe_state(&mut DRM_STATE.lock().unwrap(), ProbeState::Available(Instant::now(), list)); + return; + } + _ => std::thread::sleep(Duration::from_millis(300)), + } + } + log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)"); +} + +/// The service holds its answer until the topology settles. Replaces only an `Available` verdict. +pub(super) async fn refresh_displays_for_login() { + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let t = Instant::now(); + match query_displays_inner().await { + Ok(list) if !list.is_empty() => { + let changed = { + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + log::debug!( + "drm: login display refresh superseded while probing; keeping the newer list" + ); + return; + } + match &*st { + ProbeState::Available(_, old) => { + let changed = *old != list; + log::debug!( + "drm: login display refresh -> {} display(s) in {:?}{}", + list.len(), + t.elapsed(), + if changed { " (list changed)" } else { "" } + ); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + changed + } + _ => return, + } + }; + if changed { + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + Ok(_) => log::debug!( + "drm: login display refresh found no displays in {:?}; keeping the cached list", + t.elapsed() + ), + Err(err) => log::debug!( + "drm: login display refresh failed in {:?} ({err}); keeping the cached list", + t.elapsed() + ), + } +} + +/// Mirrors get_display_infos: only a MULTI-display host advertises a demoted display. +pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { + // Snapshot the identity keys under DRM_STATE, then consult health with DRM_STATE RELEASED -- + // same order as get_display_infos: never hold DRM_STATE while taking a per-display map. + let (len, keys): (usize, Vec) = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => ( + list.len(), + if list.len() > 1 { + list.iter().map(connector_key).collect() + } else { + Vec::new() + }, + ), + _ => return None, + }; + let any_demoted = if len > 1 { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + keys.iter() + .any(|k| health.get(k).is_some_and(|h| h.demoted())) + } else { + false + }; + Some((len, any_demoted)) +} + +// A multi-display portal stream cannot replace one demoted connector. Keep its index but mark it +// offline; a single connector remains usable through the whole-desktop fallback. +fn mark_demoted_displays(list: &[DrmDisplayInfo], infos: &mut [DisplayInfo]) { + if list.len() <= 1 { + return; + } + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (display, info) in list.iter().zip(infos.iter_mut()) { + if health + .get(&connector_key(display)) + .is_some_and(|health| health.demoted()) + { + info.online = false; + } + } +} + +fn primary_index_from_assignment(assignment: &[Option], primary: usize) -> usize { + assignment + .iter() + .position(|assigned| *assigned == Some(primary)) + .unwrap_or(0) +} + +/// Releases DRM_STATE before taking the Wayland and health locks. +pub(super) fn get_display_infos_and_primary() -> Option<(Vec, usize)> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(&list, &wl.displays); + let mut infos = augment_with_wayland_geometry_from(&list, &wl, &assignment); + mark_demoted_displays(&list, &mut infos); + // Primary and geometry must use the same connector assignment snapshot. + let primary = primary_index_from_assignment(&assignment, wl.primary); + Some((infos, primary)) +} + +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let mut infos = augment_with_wayland_geometry(&list); + mark_demoted_displays(&list, &mut infos); + Some(infos) +} + +/// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +/// +/// Asked at login screens too, on purpose: a greeter runs a compositor, and the socket fallback in +/// hbb_common lets the enumerator reach it with no environment variables. Where that fallback +/// cannot answer, the list comes back empty and everything stays unaugmented, which is what the +/// old is-login-screen gate produced unconditionally. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let assignment = assign_wayland_outputs(drm, &wl.displays); + augment_with_wayland_geometry_from(drm, &wl, &assignment) +} + +fn augment_with_wayland_geometry_from( + drm: &[DrmDisplayInfo], + wl: &scrap::wayland::display::Displays, + matched: &[Option], +) -> Vec { + let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); + // A single display is still augmented: on a multi-GPU host the one connector this service can + // open may sit at a non-zero origin in the compositor layout, and DRM alone reports (0,0). + if drm.is_empty() { + return infos; + } + if wl.displays.is_empty() { + return infos; + } + // One connector against one output is the origin-only case: the lone output can still sit at + // a non-zero origin this side cannot see, but it keeps the scale-1 convention — a single + // display is advertised at physical size (see `logical_rects_of`), so its logical size must + // not be adopted. More connectors than the one output is an inconsistent snapshot, and the + // layout-order fallback in `assign_wayland_outputs` would plant that origin on a guess. + let origin_only = wl.displays.len() == 1; + if origin_only && drm.len() > 1 { + return infos; + } + for (i, info) in infos.iter_mut().enumerate() { + let Some(w) = matched[i].map(|j| &wl.displays[j]) else { + continue; + }; + info.x = w.x; + info.y = w.y; + if origin_only { + continue; + } + if let Some((lw, lh)) = w.logical_size { + if lw > 0 && lh > 0 { + info.scale = drm[i].width as f64 / lw as f64; + info.original_resolution = super::display_service::get_original_resolution( + &drm[i].name, + lw as usize, + lh as usize, + ); + } + } + } + infos +} + +/// Each output goes to at most one connector; unmatched ones take the next free output of the same +/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at +/// DRM's (0,0). +fn assign_wayland_outputs( + drm: &[DrmDisplayInfo], + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], +) -> Vec> { + let mut taken = vec![false; wl.len()]; + let mut matched: Vec> = vec![None; drm.len()]; + for (i, d) in drm.iter().enumerate() { + if let Some(j) = match_wayland_display(d, wl, &taken) { + matched[i] = Some(j); + taken[j] = true; + } + } + for (i, d) in drm.iter().enumerate() { + if matched[i].is_some() { + continue; + } + let free_same_size = wl + .iter() + .enumerate() + .position(|(j, w)| !taken[j] && w.width == d.width as i32 && w.height == d.height as i32); + let Some(j) = free_same_size.or_else(|| taken.iter().position(|t| !t)) else { + continue; // more connectors than outputs; leave the rest unaugmented + }; + log::warn!( + "drm: connector {} matched no compositor output by name or by a unique resolution; \ + falling back to layout order and taking {} at ({}, {})", + d.name, + wl[j].name, + wl[j].x, + wl[j].y + ); + matched[i] = Some(j); + taken[j] = true; + } + matched +} + +fn match_wayland_display( + d: &DrmDisplayInfo, + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], + taken: &[bool], +) -> Option { + let dn = normalize_connector(&d.name); + if let Some((j, _)) = wl + .iter() + .enumerate() + .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) + { + return Some(j); + } + let same_res: Vec = wl + .iter() + .enumerate() + .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) + .map(|(j, _)| j) + .collect(); + if same_res.len() == 1 { + return Some(same_res[0]); + } + None +} + +/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1"). +/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2". +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 3 && parts[1].len() == 1 && parts[1].chars().all(|c| c.is_ascii_alphabetic()) { + format!("{}-{}", parts[0], parts[2]) + } else { + name.to_string() + } +} + +fn swap_available_displays(list: Vec) { + let mut st = DRM_STATE.lock().unwrap(); + if matches!(&*st, ProbeState::Available(..)) { + if list.is_empty() { + log::info!("drm: hotplug refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!("drm: hotplug refresh -> {} display(s)", list.len()); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + } + } +} + +fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo { + let original_resolution = + super::display_service::get_original_resolution(&d.name, d.width as usize, d.height as usize); + DisplayInfo { + x: d.x, + y: d.y, + width: d.width as i32, + height: d.height as i32, + name: d.name.clone(), + online: d.active, + cursor_embedded: false, + original_resolution, + scale: 1.0, + ..Default::default() + } +} + +/// Deliberately does NOT publish the handshake list into DRM_STATE: it is read before a possibly +/// seconds-long stall, and when `wire_idx != display_idx` it is ordered differently. +pub(super) fn get_capturer_info( + display_idx: usize, +) -> ResultType { + let expected = display_info_of(display_idx as i32); + let key = expected.as_ref().map(connector_key); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + if let Some(h) = key.as_ref().and_then(|k| map.get_mut(k)) { + if h.zero_frame_streak >= DRM_GRAB_MAX_FAILURES { + if h.demoted() { + bail!( + "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" + ); + } + h.zero_frame_streak = 0; + h.since = Instant::now(); + } + } + } + // Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below. + let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?; + // The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window. + if let Some(key) = key.clone() { + let now = Instant::now(); + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.rapid_builds = match h.last_build { + Some(last) if now.duration_since(last) < RAPID_REBUILD_WINDOW => h.rapid_builds + 1, + _ => 0, + }; + h.last_build = Some(now); + if h.rapid_builds >= RAPID_REBUILD_MAX { + log::warn!( + "drm: display {display_idx} rebuilt {} times within {RAPID_REBUILD_WINDOW:?}; flapping, falling back to PipeWire", + h.rapid_builds + ); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.since = now; + h.demotes += 1; + bail!("drm capture for display {display_idx} is flapping; using PipeWire"); + } + } + let ndisplay = displays.len(); + // From the entry the stream was BOUND to; `display_idx` is a position in the CLIENT's list. + let d = displays + .get(wire_idx) + .ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))? + .clone(); + // Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin + // matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer. + let origin = augment_with_wayland_geometry(&displays) + .get(wire_idx) + .map(|di| (di.x, di.y)) + .unwrap_or((d.x, d.y)); + Ok(super::video_service::CapturerInfo { + origin, + width: d.width as usize, + height: d.height as usize, + ndisplay, + current: display_idx, + privacy_mode_id: 0, + _capturer_privacy_mode_id: 0, + capturer: Box::new(capturer), + }) +} + +#[cfg(test)] +mod drm_capturer_tests { + use super::*; + + fn capturer_with(session: Option<(usize, usize)>) -> IpcDrmCapturer { + capturer_named(session, None) + } + + // DRM_DISPLAY_HEALTH is process-wide and tests run in parallel: pass each test its OWN key. + fn capturer_named(session: Option<(usize, usize)>, key: Option<&str>) -> IpcDrmCapturer { + let connector = key.map(|k| k.to_owned()); + IpcDrmCapturer { + shared: Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }), + stop: Arc::new(AtomicBool::new(false)), + display: 0, + connector, + session_size: session, + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + } + } + + fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 { + let key = c.connector.clone().expect("this check needs an identity"); + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(&key) + .map(|h| h.zero_frame_streak) + .unwrap_or(0) + } + + fn put_frame(c: &IpcDrmCapturer, w: usize, h: usize) { + let mut buf = c.shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.resize(w * h * 4, 0); + let mut slot = c.shared.slot.lock().unwrap(); + slot.publish(w, h, Pixfmt::BGRA, buf); + } + + #[test] + fn a_delivered_frame_clears_the_streak_but_keeps_the_cadence_and_the_convert_verdict() { + let key = "test:frame-keeps-cadence"; + let mut c = capturer_named(Some((64, 32)), Some(key)); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key.to_owned()).or_insert_with(DisplayHealth::new); + h.zero_frame_streak = 2; + h.demotes = 1; + h.rapid_builds = 3; + h.last_build = Some(Instant::now()); + h.prefer_cpu = true; + } + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + + // Copy out and RELEASE the guard before asserting: a failing assertion while holding + // process-wide DRM_DISPLAY_HEALTH poisons the mutex for every sibling test. + let h = { + let map = DRM_DISPLAY_HEALTH.lock().unwrap(); + *map.get(key).expect("the entry must SURVIVE a delivered frame") + }; + assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); + assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); + assert_eq!( + h.rapid_builds, 3, + "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ + reach RAPID_REBUILD_MAX for a display that delivers a first frame and then fails" + ); + assert!(h.last_build.is_some(), "same for the timestamp the cadence is measured from"); + assert!( + h.prefer_cpu, + "and nothing about which GPU exports the scanout: only a topology change may clear it" + ); + } + + #[test] + fn frame_of_the_session_size_is_delivered() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!( + matches!(c.frame(Duration::from_millis(50)), Ok(_)), + "a frame matching the session geometry must be delivered" + ); + assert!(c.got_frame); + } + + #[test] + fn a_smaller_frame_ends_the_session_instead_of_being_encoded() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:mid-session-shrink")); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a mid-session shrink must be a hard error, not a delivered frame"), + }; + assert!(err.to_string().contains("changed geometry mid-session")); + assert!( + c.got_frame, + "the rebuild must not look like a display that never produced a frame" + ); + assert_eq!( + zero_frame_streak_of(&c), + 0, + "a session that streamed must not be counted as one that produced nothing" + ); + } + + #[test] + fn a_first_frame_that_never_matched_counts_as_a_session_without_frames() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:never-matched")); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a first frame off the advertised geometry must be a hard error"), + }; + assert!(err.to_string().contains("never matched its advertised geometry")); + assert!(!c.got_frame, "no frame reached the encoder, so none was produced"); + assert_eq!( + zero_frame_streak_of(&c), + 1, + "the display must be on its way to a PipeWire demotion, not just rebuilding" + ); + } + + #[test] + fn a_larger_frame_ends_the_session_too() { + let mut c = capturer_with(Some((1280, 720))); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Err(_))); + } + + #[test] + fn unknown_session_size_delivers_whatever_arrives() { + let mut c = capturer_with(None); + put_frame(&c, 800, 600); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + } + + fn drm_display(name: &str, w: u32, h: u32) -> DrmDisplayInfo { + DrmDisplayInfo { + name: name.to_owned(), + crtc_id: 1, + x: 0, + y: 0, + width: w, + height: h, + active: true, + render_node: String::new(), + device: String::new(), + } + } + + fn wl_display( + name: &str, + x: i32, + y: i32, + w: i32, + h: i32, + ) -> hbb_common::platform::linux::WaylandDisplayInfo { + hbb_common::platform::linux::WaylandDisplayInfo { + name: name.to_owned(), + x, + y, + width: w, + height: h, + logical_size: Some((w, h)), + refresh_rate: 60, + } + } + + #[test] + fn one_connector_assignment_drives_geometry_and_primary() { + let drm = [ + drm_display("HDMI-A-1", 1920, 1080), + drm_display("DP-1", 2560, 1440), + ]; + let wl = scrap::wayland::display::Displays { + primary: 0, + displays: vec![ + wl_display("DP-1", 1920, 0, 2560, 1440), + wl_display("HDMI-1", 0, 0, 1920, 1080), + ], + }; + + let assignment = assign_wayland_outputs(&drm, &wl.displays); + let infos = augment_with_wayland_geometry_from(&drm, &wl, &assignment); + assert_eq!((infos[0].x, infos[1].x), (0, 1920)); + assert_eq!(primary_index_from_assignment(&assignment, wl.primary), 1); + } + + #[test] + fn frame_buffers_circulate_instead_of_being_reallocated() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + put_frame(&c, 64, 32); + let recycled = c + .shared + .slot + .lock() + .unwrap() + .free + .iter() + .find_map(|b| b.as_ref()) + .map(|b| b.as_ptr()); + assert!( + recycled.is_some(), + "a superseded frame must be handed back, not dropped" + ); + put_frame(&c, 64, 32); + assert_eq!( + c.shared + .slot + .lock() + .unwrap() + .latest + .as_ref() + .map(|(.., b)| b.as_ptr()), + recycled, + "the receive path must refill the recycled buffer rather than allocate" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert!( + c.shared.slot.lock().unwrap().free.iter().any(|b| b.is_some()), + "the buffer the encoder finished with must be handed back to the receive path" + ); + } + + // Against a single free slot this asserts red: counting the offers is the point. + #[test] + fn two_idle_buffers_are_both_kept_rather_than_one_being_dropped() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + while c.shared.slot.lock().unwrap().take_free().is_some() {} + + put_frame(&c, 64, 32); // fills a fresh buffer (nothing on offer) and publishes it + put_frame(&c, 64, 32); // supersedes it -> deposit #1 + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 1, + "the superseded frame is the first idle buffer" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 2, + "both idle buffers must be kept; a single slot dropped the older one" + ); + } + + #[test] + fn outputs_are_matched_by_name_across_the_drm_naming_difference() { + let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; + let wl = [wl_display("DP-1", 1920, 0, 2560, 1440), wl_display("HDMI-1", 0, 0, 1920, 1080)]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(1), Some(0)]); + } + + // The M10 case: same model and resolution, names that do not normalize to the compositor's. + #[test] + fn identical_monitors_that_match_no_name_take_layout_order() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn one_output_is_never_claimed_by_two_connectors() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 3840, 2160), + ]; + let got = assign_wayland_outputs(&drm, &wl); + assert_eq!(got[0], Some(0)); + assert_ne!(got[0], got[1], "two connectors must not share one output"); + } + + #[test] + fn a_name_match_beats_the_positional_fallback() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("HDMI-A-1", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("HDMI-1", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn extra_connectors_stay_unmatched() { + let drm = [ + drm_display("DP-1", 1920, 1080), + drm_display("DP-2", 1920, 1080), + drm_display("DP-3", 1920, 1080), + ]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1), None]); + } + + #[test] + fn refresh_keeps_a_verdict_through_one_failure_and_gives_it_up_after_a_run() { + assert_eq!(refresh_outcome(Some(3), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(1), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(0), 0), RefreshOutcome::Unavailable); + assert_eq!(refresh_outcome(None, 1), RefreshOutcome::Restamp); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES - 1), + RefreshOutcome::Restamp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES), + RefreshOutcome::GiveUp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES + 5), + RefreshOutcome::GiveUp + ); + } + + #[test] + fn a_dead_producer_stops_being_advertised() { + let mut outcome = RefreshOutcome::Restamp; + for failures in 1..=DRM_REFRESH_MAX_FAILURES { + outcome = refresh_outcome(None, failures); + } + assert_eq!(outcome, RefreshOutcome::GiveUp); + assert!( + DRM_REFRESH_MAX_FAILURES >= 2, + "a single transient failure must never be enough to drop the verdict" + ); + } + + #[test] + fn health_reports_demoted_only_while_the_cooldown_runs() { + let mut h = DisplayHealth::new(); + assert!(!h.demoted(), "a fresh display is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES - 1; + assert!(!h.demoted(), "one session short of the threshold is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.demotes = 1; + assert!(h.demoted(), "at the threshold, inside the cooldown"); + h.since = Instant::now() - demote_cooldown(h.demotes) - Duration::from_secs(1); + assert!(!h.demoted(), "past the cooldown the display must be retried"); + h.demotes = 4; + assert!(h.demoted(), "the backoff must still be holding it at demotion 4"); + } + + #[test] + fn demote_cooldown_doubles_per_cycle_and_caps() { + assert_eq!(demote_cooldown(1), DEMOTE_COOLDOWN); + assert_eq!(demote_cooldown(2), DEMOTE_COOLDOWN * 2); + assert_eq!(demote_cooldown(3), DEMOTE_COOLDOWN * 4); + let cap = DEMOTE_COOLDOWN * (1 << DEMOTE_BACKOFF_MAX_SHIFT); + assert_eq!(demote_cooldown(1 + DEMOTE_BACKOFF_MAX_SHIFT), cap); + assert_eq!(demote_cooldown(50), cap); + assert_eq!(demote_cooldown(u32::MAX), cap); + assert_eq!(demote_cooldown(0), DEMOTE_COOLDOWN); + } + + #[test] + fn a_permanently_ungrabbable_display_stops_churning() { + let burn = Duration::from_secs(5); // four failed sessions + assert!(demote_cooldown(1) + burn < Duration::from_secs(40)); + assert!(demote_cooldown(5) + burn > Duration::from_secs(8 * 60)); + } +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 1d4deeb65..f8f943276 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -396,19 +396,62 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> if let Some(hcursor) = crate::get_cursor()? { if hcursor != state.hcursor { let msg; + // On the DRM path get_cursor_data() may return a snapshot whose id has advanced past the + // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND + // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact + // shape dedupes correctly instead of being suppressed. Everything below is fully + // gated on the drm feature, so the drm-off build stays byte-identical to upstream. + #[cfg(all(target_os = "linux", feature = "drm"))] + let mut drm_served_id = hcursor; if let Some(cached) = state.cached_cursor_data.get(&hcursor) { super::log::trace!("Cursor data cached, hcursor: {}", hcursor); msg = cached.clone(); } else { let mut data = crate::get_cursor_data(hcursor)?; + // File the shape under the id ACTUALLY served, not the one requested. Deliberately a + // NEW name rather than shadowing `hcursor`: the insert below reads as the requested + // id everywhere else in this function, and a cfg-gated shadow would make the two + // builds disagree about what that line means. + #[cfg(all(target_os = "linux", feature = "drm"))] + let served_id = data.id; + #[cfg(all(target_os = "linux", feature = "drm"))] + { + drm_served_id = served_id; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + let cache_key = served_id; + #[cfg(not(all(target_os = "linux", feature = "drm")))] + let cache_key = hcursor; data.colors = hbb_common::compress::compress(&data.colors[..]).into(); let mut tmp = Message::new(); tmp.set_cursor_data(data); msg = Arc::new(tmp); - state.cached_cursor_data.insert(hcursor, msg.clone()); - super::log::trace!("Cursor data updated, hcursor: {}", hcursor); + // A DRM cursor id is derived from the shape's pixels plus geometry, so an animated + // pointer mints a new id on every shape change and this map would grow for the life + // of the service, each entry pinning a compressed cursor message. (Upstream's X11 + // ids come from a small set of XFixes serials, so the map is effectively bounded + // there -- which is why the ceiling is gated and the stock build stays untouched.) + // Past the ceiling, drop the map and start over: the next request for any evicted + // shape just recompresses it, and the ceiling comfortably covers every static shape + // plus a generous animation window. + #[cfg(all(target_os = "linux", feature = "drm"))] + { + const CURSOR_CACHE_MAX: usize = 64; + if state.cached_cursor_data.len() >= CURSOR_CACHE_MAX { + state.cached_cursor_data.clear(); + } + } + state.cached_cursor_data.insert(cache_key, msg.clone()); + super::log::trace!("Cursor data updated, hcursor: {}", cache_key); + } + #[cfg(not(all(target_os = "linux", feature = "drm")))] + { + state.hcursor = hcursor; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + { + state.hcursor = drm_served_id; } - state.hcursor = hcursor; sp.send_shared(msg.clone()); state.cursor_data = msg; } @@ -620,17 +663,22 @@ pub async fn setup_uinput(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultT let mouse = super::uinput::client::UInputMouse::new().await?; log::info!("UInput mouse created"); - ENIGO - .lock() - .unwrap() - .set_custom_keyboard(Box::new(keyboard)); - ENIGO.lock().unwrap().set_custom_mouse(Box::new(mouse)); + let mut en = ENIGO.lock().unwrap(); + // enigo guessed x11 once at construction, which is what a Wayland greeter reads as, and + // then routes the devices installed below to a null xdo that drops everything silently. + // Reaching here means `wayland_use_uinput()` was true, so this states a fact. + en.set_is_x11(false); + // One lock for both, so there is no window where the keyboard is custom and the mouse is not. + en.set_custom_keyboard(Box::new(keyboard)); + en.set_custom_mouse(Box::new(mouse)); Ok(()) } #[cfg(target_os = "linux")] pub async fn setup_rdp_input() -> ResultType<(), Box> { let mut en = ENIGO.lock()?; + // Same as `setup_uinput`: the caller is gated on `wayland_use_rdp_input()`. + en.set_is_x11(false); let rdp_info_lock = RDP_SESSION_INFO.lock()?; let rdp_info = rdp_info_lock.as_ref().ok_or("RDP session is None")?; diff --git a/src/server/wayland.rs b/src/server/wayland.rs index dacce9485..023e9e559 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,124 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Uinput desktop rect from the DRM display list, for a login screen where no compositor can be +/// asked. `(minx, maxx, miny, maxy)`, in scanout pixels: no compositor here applied a scale, so +/// unlike `desktop_rect_of` there is no logical size to handle. +#[cfg(feature = "drm")] +fn drm_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { + let displays = super::drm_capturer::get_display_infos()?; + if displays.is_empty() { + return None; + } + let minx = displays.iter().map(|d| d.x).min()?; + let miny = displays.iter().map(|d| d.y).min()?; + let maxx = displays.iter().map(|d| d.x + d.width).max()?; + let maxy = displays.iter().map(|d| d.y + d.height).max()?; + if maxx <= minx || maxy <= miny { + return None; + } + Some((minx, maxx, miny, maxy)) +} + +/// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps +/// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The +/// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it +/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the +/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured +/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is +/// independent of the capture backend. +/// +/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the +/// same three things, for the same reasons: +/// +/// - drops the cached Wayland layout first, because it can predate compositor changes made while no +/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition; +/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of +/// its own, so a hung uinput socket would otherwise block every video-service start on this branch +/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so +/// that every later hotplug refresh is silently skipped for the process lifetime; +/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the +/// #15601 drift remap. Without it the remap never activates on the DRM path at all. +/// +/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in +/// every Linux build and this feature must not change the drm-off one by so much as a line. +#[cfg(feature = "drm")] +pub(super) async fn update_uinput_resolution() { + if !crate::input_service::wayland_use_uinput() { + return; + } + // Compositor first at a login screen too: a greeter runs one, and the hbb_common socket + // fallback reaches it with no environment variables. The DRM union is the fallback, and it is + // a real loss to land there on a multi-monitor host: DRM has no origins, so its union rect + // mis-maps the pointer whenever the compositor arranged the outputs side by side. + // + // Off the executor: the compositor query can block for the socket probe deadline, and this + // runs on current-thread runtimes (session init and the hotplug worker). The layout baseline + // is computed in the SAME task: a failed lookup is not cached, so asking for the rects + // afterwards would rerun the whole socket probe synchronously. + let (rect, layout) = match hbb_common::tokio::task::spawn_blocking(|| { + scrap::wayland::display::clear_wayland_displays_cache(); + match scrap::wayland::display::get_desktop_rect_for_uinput() { + // The lookup above just cached the displays, so the rects come from that snapshot. + Some(rect) => Some((rect, scrap::wayland::display::get_display_rects_for_uinput())), + // Raw DRM union: there is no compositor layout to baseline. Empty keeps the #15601 + // remap inactive, which is right when the origins are unknown anyway. + None => drm_desktop_rect_for_uinput().map(|rect| (rect, Vec::new())), + } + }) + .await + { + Ok(Some(pair)) => pair, + Ok(None) => { + log::warn!("Failed to get desktop rect for uinput"); + return; + } + Err(err) => { + log::warn!("The desktop rect probe task failed: {err}"); + return; + } + }; + // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and + // the baseline is what the client's coordinates are measured against. + let snapshot_layout = || { + super::display_service::set_wayland_layout_baseline(layout.clone()); + }; + // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop + // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a + // uinput device reconfiguration under a user who may be at the console. + if super::display_service::wayland_uinput_rect() == Some(rect) { + snapshot_layout(); + return; + } + let (minx, maxx, miny, maxy) = rect; + log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})"); + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + // Record the rect only after a successful apply, so a transient failure is retried on the + // next call instead of being remembered as applied. + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(rect); + snapshot_layout(); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } +} + #[tokio::main(flavor = "current_thread")] pub(super) async fn ensure_inited() -> ResultType<()> { + // DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over + // IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput + // desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init). + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + update_uinput_resolution().await; + return Ok(()); + } check_init().await } @@ -116,6 +232,10 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + return None; + } if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { @@ -242,6 +362,27 @@ 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; + 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?; // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); @@ -260,6 +401,19 @@ pub fn clear() { if is_x11() { return; } + // The DRM path augments its geometry from the compositor's Wayland outputs (logical origin + + // scale), which scrap caches process-wide. The PipeWire path clears that cache on session close, + // but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs + // against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown + // so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + scrap::wayland::display::clear_wayland_displays_cache(); + } + // NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer + // teardown (which happens on each video-service restart), and re-probing `_drm` from the async + // enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral + // into a restart loop. DRM availability is fixed at service start, so the cache stays valid. let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); for (_, addr) in write_lock.iter() { let cap_display_info: *mut CapDisplayInfo = *addr as _; @@ -274,18 +428,136 @@ pub fn clear() { *PIPEWIRE_INITIALIZED.write().unwrap() = false; } +/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display +/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits +/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper +/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed +/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so +/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video +/// service instead of degrading to PipeWire only for itself. +#[cfg(feature = "drm")] +#[tokio::main(flavor = "current_thread")] +async fn ensure_pipewire_inited() -> ResultType<()> { + check_init().await +} + pub(super) fn get_capturer_for_display( display_idx: usize, ) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } + // DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing + // the PipeWire CAP_DISPLAY_INFO machinery entirely. `is_available()` is a GLOBAL verdict, so a + // per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a + // render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out + // and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this + // display; the other DRM outputs keep streaming over DRM. + // The ONE gate that keeps the probing form on purpose: this runs on the plain video thread, + // not an async executor, and it is the capture-build path, so a definitive verdict is worth + // seconds here. It is also what makes a cold cache recoverable at all -- warm_availability + // gives up after its attempts, so if EVERY gate were cache-only a --server that started + // before the root service would never see DRM again for the rest of its life. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + match super::drm_capturer::get_capturer_info(display_idx) { + Ok(info) => return Ok(info), + Err(e) => { + log::warn!( + "drm capturer for display {} unavailable ({:#}); falling back to PipeWire", + display_idx, + e + ); + ensure_pipewire_inited()?; + } + } + } + // Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs + // `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes + // the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM + // display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard + // across that roundtrip would stall every concurrent teardown for its duration, and the value + // does not depend on anything inside the guard. + #[cfg(feature = "drm")] + let drm_advertised = if super::drm_capturer::is_available_cached() { + match super::drm_capturer::get_display_infos() { + Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)), + None => Some((None, false)), + } + } else { + None + }; let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + // Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's + // `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing + // one entry to two `display_idx` values would let two video-service threads call `frame()` on the + // same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong + // rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop + // stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is + // handled at the source by dropping the demoted display from the advertised list (see + // drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being + // papered over with a shared/mismatched capturer. if let Some(addr) = cap_map.get(&display_idx) { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; let rect = cap_display_info.rects[cap_display_info.current]; + // Reaching here with DRM active means get_capturer_info bailed (a demoted display) and + // we fell through to PipeWire. Serve this stream ONLY if its rect matches the + // geometry we advertised for this index. The portal typically exposes one whole-desktop + // stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM + // geometry is a single connector -> serving it would stretch the frame and offset all + // input. Bail instead; get_display_infos advertised the display offline, so the client + // re-enumerates against a consistent list. A single-display host matches (whole-desktop == + // that display) and is served normally. On a pure-PipeWire host is_available() is false and + // this guard is skipped, preserving upstream behavior exactly. + #[cfg(feature = "drm")] + if let Some((advertised, single_display)) = drm_advertised { + if let Some(advertised) = advertised { + // BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed, + // because it was twice "corrected" to a scale conversion that broke it: + // `rect` is built above from `Display::width()/height()`, and the WAYLAND + // variant of those returns `physical_width()/physical_height()` + // (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`. + // `try_fix_logical_size` only repairs the capturable's SEPARATE + // `logical_size` field and never touches `physical_size`, so the rect is not + // logical. The advertised DRM geometry is physical too + // (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves + // width/height as the DRM mode). Dividing one side by the scale therefore + // compares logical against physical and rejects the valid stream on exactly + // the scaled outputs it was meant to rescue. + // + // The size check is what tells one connector apart from the whole-desktop + // rect the portal usually exposes. It is skipped only when BOTH sides say + // there is a single display -- the DRM list has one entry and the PipeWire + // map has one -- because only then is "the whole-desktop stream IS this + // display" true by construction. (The portal can report a different physical + // size for a Full Workspace selection than the connector's mode, which is why + // that case needs the carve-out at all.) The DRM count alone is not enough: + // a monitor on a card the service cannot open is missing from the DRM list + // while the compositor still drives it. + let single_display = single_display && cap_display_info.num == 1; + let consistent = advertised.x == rect.0 .0 + && advertised.y == rect.0 .1 + && (single_display + || (advertised.width as usize == rect.1 + && advertised.height as usize == rect.2)); + if !consistent { + bail!( + "drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", + display_idx, + advertised.width, + advertised.height, + advertised.x, + advertised.y, + rect.1, + rect.2, + rect.0 .0, + rect.0 .1 + ); + } + } + } Ok(super::video_service::CapturerInfo { origin: rect.0, width: rect.1, diff --git a/src/tray.rs b/src/tray.rs index 0b7e38542..f585b3f42 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -153,6 +153,7 @@ fn make_tray() -> hbb_common::ResultType<()> { // We create the icon once the event loop is actually running // to prevent issues like https://github.com/tauri-apps/tray-icon/issues/90 let mut builder = TrayIconBuilder::new() + .with_id(crate::get_app_name().to_lowercase()) .with_menu(Box::new(tray_menu.clone())) .with_tooltip(tooltip(0)) .with_icon(icon.clone()); diff --git a/src/ui/common.tis b/src/ui/common.tis index a1a0b8fac..8b90b4a43 100644 --- a/src/ui/common.tis +++ b/src/ui/common.tis @@ -272,30 +272,6 @@ function msgbox(type, title, content, link="", callback=null, height=180, width= handler.send2fa(res.code, res.trust_this_device || false); msgbox("connecting", "Connecting...", "Logging in..."); }; - } else if (type == "session-login" || type == "session-re-login") { - callback = function (res) { - if (!res) { - view.close(); - return; - } - handler.login(res.osusername, res.ospassword, "", false); - if (!is_port_forward) { - if (is_file_transfer) handler.msgbox("connecting", "Connecting...", "Logging in..."); - else msgbox("connecting", "Connecting...", "Logging in..."); - } - }; - } else if (type.indexOf("session-login") >= 0) { - callback = function (res) { - if (!res) { - view.close(); - return; - } - handler.login(res.osusername, res.ospassword, res.password, res.remember); - if (!is_port_forward) { - if (is_file_transfer) handler.msgbox("connecting", "Connecting...", "Logging in..."); - else msgbox("connecting", "Connecting...", "Logging in..."); - } - }; } else if (type.indexOf("insecure-connection") >= 0) { callback = function (res) { if (!res) { diff --git a/src/ui/msgbox.tis b/src/ui/msgbox.tis index 58547ce58..14b0bae02 100644 --- a/src/ui/msgbox.tis +++ b/src/ui/msgbox.tis @@ -41,7 +41,7 @@ class MsgboxComponent: Reactor.Component { } function getIcon(color) { - if (this.type == "input-password" || this.type == "session-login" || this.type == "session-login-password" || this.type == "input-2fa") { + if (this.type == "input-password" || this.type == "input-2fa") { return ; } if (this.type == "connecting") { @@ -50,7 +50,7 @@ class MsgboxComponent: Reactor.Component { if (this.type == "success") { return ; } - if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "input-2fa" || this.type == "session-re-login" || this.type == "session-login-re-password") { + if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "input-2fa") { return ; } return null; @@ -74,37 +74,11 @@ class MsgboxComponent: Reactor.Component { ; } - function getInputUserPasswordContent() { - return
-
{translate("OS Username")}
-
-
{translate("OS Password")}
- -
-
; - } - - function getXsessionPasswordContent() { - return
-
{translate("OS Username")}
-
-
{translate("OS Password")}
- -
{translate('Please enter your password')}
- -
{translate('Remember password')}
-
; - } - function getContent() { if (this.type == "input-password") { return this.getInputPasswordContent(); } else if (this.type == "input-2fa") { return this.get2faContent(); - } else if (this.type == "session-login") { - return this.getInputUserPasswordContent(); - } else if (this.type == "session-login-password") { - return this.getXsessionPasswordContent(); } else if (this.type == "custom-os-password") { var ts = this.autoLogin ? { checked: true } : {}; return
@@ -116,13 +90,13 @@ class MsgboxComponent: Reactor.Component { } function getColor() { - if (this.type == "input-password" || this.type == "input-2fa" || this.type == "custom-os-password" || this.type == "session-login" || this.type == "session-login-password") { + if (this.type == "input-password" || this.type == "input-2fa" || this.type == "custom-os-password") { return "#AD448E"; } if (this.type == "success") { return "#32bea6"; } - if (this.type.indexOf("error") >= 0 || this.type == "re-input-password" || this.type == "session-re-login" || this.type == "session-login-re-password") { + if (this.type.indexOf("error") >= 0 || this.type == "re-input-password") { return "#e04f5f"; } return "#2C8CFF"; @@ -242,16 +216,6 @@ class MsgboxComponent: Reactor.Component { this.update(); return; } - if (this.type == "session-re-login") { - this.type = "session-login"; - this.update(); - return; - } - if (this.type == "session-login-re-password") { - this.type = "session-login-password"; - this.update(); - return; - } var values = this.getValues(); if (this.callback) { var self = this; @@ -352,21 +316,6 @@ class MsgboxComponent: Reactor.Component { return; } } - if (this.type == "session-login") { - values.osusername = (values.osusername || "").trim(); - values.ospassword = (values.ospassword || "").trim(); - if (!values.osusername || !values.ospassword) { - return; - } - } - if (this.type == "session-login-password") { - values.password = (values.password || "").trim(); - values.osusername = (values.osusername || "").trim(); - values.ospassword = (values.ospassword || "").trim(); - if (!values.osusername || !values.ospassword || !values.password) { - return; - } - } if (this.type == "multiple-sessions-nocancel") { values.sid = (this.$$(select))[0].value; } diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index b62f59c54..c659170e3 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -377,6 +377,15 @@ pub fn close(id: i32) { }; } +/// Like `close`, but says the CM's WINDOW closed rather than a person disconnecting this peer. +/// See `ipc::Data::CmWindowClosed`. +#[cfg(target_os = "linux")] +pub fn close_window(id: i32) { + if let Some(client) = CLIENTS.read().unwrap().get(&id) { + allow_err!(client.tx.send(Data::CmWindowClosed)); + }; +} + #[inline] pub fn remove(id: i32) { CLIENTS.write().unwrap().remove(&id); @@ -968,6 +977,61 @@ async fn handle_fs( tx_log: Option<&UnboundedSender>, _conn_id: i32, ) { + // Android is scoped-storage only, so every peer supplied path has to stay inside the + // app workspace. This is the filesystem boundary, keep it enforced here even though + // `Connection` rejects out-of-workspace requests earlier as well. + #[cfg(target_os = "android")] + { + // (path, job id, file num, allow empty) of the peer supplied path this message + // acts on. + let checked: Option<(&str, i32, i32, bool)> = match &fs { + ipc::FS::ReadEmptyDirs { dir, .. } => Some((dir.as_str(), -1, -1, false)), + ipc::FS::ReadDir { dir, .. } => Some((dir.as_str(), -1, -1, true)), + ipc::FS::RemoveDir { path, id, .. } | ipc::FS::CreateDir { path, id } => { + Some((path.as_str(), *id, 0, false)) + } + ipc::FS::Rename { path, id, .. } => Some((path.as_str(), *id, 0, false)), + ipc::FS::RemoveFile { path, id, file_num } => { + Some((path.as_str(), *id, *file_num, false)) + } + ipc::FS::ReadAllFiles { path, id, .. } => Some((path.as_str(), *id, -1, false)), + ipc::FS::NewWrite { + path, id, file_num, .. + } + | ipc::FS::ReadFile { + path, id, file_num, .. + } => Some((path.as_str(), *id, *file_num, false)), + _ => None, + }; + if let Some((path, id, file_num, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!("Reject file operation outside the app workspace: {}", path); + if id >= 0 { + send_raw(fs::new_error(id, "Permission denied", file_num), tx); + } + return; + } + } + if let ipc::FS::Rename { path, new_name, id } = &fs { + let destination = std::path::Path::new(path) + .parent() + .map(|parent| parent.join(new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + send_raw(fs::new_error(*id, "Permission denied", 0), tx); + return; + } + } + } match fs { ipc::FS::ReadEmptyDirs { dir, @@ -1537,13 +1601,19 @@ async fn read_dir(dir: &str, include_hidden: bool, tx: &UnboundedSender) { fs::get_path(dir) } }; - if let Ok(Ok(fd)) = spawn_blocking(move || fs::read_dir(&path, include_hidden)).await { - let mut msg_out = Message::new(); - let mut file_response = FileResponse::new(); - file_response.set_dir(fd); - msg_out.set_file_response(file_response); - send_raw(msg_out, tx); - } + let result = spawn_blocking(move || fs::read_dir(&path, include_hidden)).await; + let msg_out = match result { + Ok(Ok(fd)) => { + let mut msg_out = Message::new(); + let mut file_response = FileResponse::new(); + file_response.set_dir(fd); + msg_out.set_file_response(file_response); + msg_out + } + Ok(Err(err)) => fs::new_error(0, err, -1), + Err(err) => fs::new_error(0, err, -1), + }; + send_raw(msg_out, tx); } #[cfg(not(any(target_os = "ios")))] @@ -1741,7 +1811,7 @@ mod tests { #[test] #[cfg(not(any(target_os = "ios")))] - fn read_dir_success() { + fn read_dir_reports_success_and_error() { let rt = Runtime::new().unwrap(); rt.block_on(async { let (tx, mut rx) = unbounded_channel(); @@ -1764,6 +1834,18 @@ mod tests { _ => panic!("unexpected data"), } let _ = fs::remove_dir_all(&dir); + + super::read_dir(&dir.to_string_lossy(), false, &tx).await; + + match rx.recv().await.unwrap() { + Data::RawMessage(bytes) => { + let mut msg = Message::new(); + msg.merge_from_bytes(&bytes).unwrap(); + assert_eq!(msg.file_response().error().id, 0); + assert!(!msg.file_response().error().error.is_empty()); + } + _ => panic!("unexpected data"), + } }); } diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index bf2e04c6b..03b59a497 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(); @@ -1878,8 +1868,8 @@ impl Interface for Session { } } - async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) { - handle_hash(self.lc.clone(), pass, hash, self, peer).await; + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { + handle_hash(self.lc.clone(), pass, hash, self, peer).await } async fn handle_login_from_ui( diff --git a/vcpkg.json b/vcpkg.json index cd282fc1c..d1cd4044a 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -25,10 +25,6 @@ "host": false, "platform": "windows & arm64" }, - { - "name": "oboe", - "platform": "android" - }, { "name": "opus", "host": true @@ -91,7 +87,7 @@ "vcpkg-configuration": { "default-registry": { "kind": "builtin", - "baseline": "120deac3062162151622ca4860575a33844ba10b" + "baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d" }, "overlay-ports": [ "./res/vcpkg"